diff --git a/CHANGELOG.md b/CHANGELOG.md index d434ad6..e4e5d5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [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) ### Added - Module `src/alerts/` : `AlertEvent` (blocked / first-exec / J-1 / digest sans diff --git a/orchestrator/src/alerts/scheduler.ts b/orchestrator/src/alerts/scheduler.ts new file mode 100644 index 0000000..4379052 --- /dev/null +++ b/orchestrator/src/alerts/scheduler.ts @@ -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 { + 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); +} diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index 7e57eb1..8d6060d 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -59,6 +59,14 @@ const schema = z.object({ // Backend 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; @@ -69,6 +77,7 @@ const SECRET_KEYS = new Set([ "mcpN8nAuthToken", "portainerMcpAuthToken", "telegramBotToken", + "alertWebhookUrl", ]); 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, telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS, dbPath: env.CHLOVA_DB_PATH, + alertWebhookUrl: env.ALERT_WEBHOOK_URL, }); if (!parsed.success) { diff --git a/orchestrator/src/gatekeeper/gatekeeper.ts b/orchestrator/src/gatekeeper/gatekeeper.ts index d353750..df477bc 100644 --- a/orchestrator/src/gatekeeper/gatekeeper.ts +++ b/orchestrator/src/gatekeeper/gatekeeper.ts @@ -16,11 +16,13 @@ import { canExecute, createAsset, type Asset } from "./assets.js"; * 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 { /** 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; } @@ -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); return { allowed: true }; } diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index aa92e75..4bcf111 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -8,6 +8,9 @@ import { Gatekeeper, GatekeeperGuard } from "./gatekeeper/gatekeeper.js"; import { AssetRepository } from "./gatekeeper/repository.js"; import { ReviewService, startExpiryCron } from "./gatekeeper/review.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 { runAgentTurn } from "./agent/loop.js"; import { buildSystemPrompt } from "./agent/system-prompt.js"; @@ -47,19 +50,35 @@ async function main(): Promise { let repo: AssetRepository | null = null; let review: ReviewService | null = null; let stopCron: (() => void) | null = null; + let stopAlerts: (() => void) | null = null; if (cfg.phase === 2) { 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, { onBlockedAttempt: (asset, spec) => - logger.warn( - { asset: asset.id, tool: spec.name, status: asset.status }, - "ALERTE : tentative sur asset bloqué (review requise)", - ), + void alerts.send({ + kind: "blocked_attempt", + 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); tools = await registry.listAllTools(); review = new ReviewService(repo, logger); stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire + stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1 } else { guard = new ReadOnlyGuard(); tools = await registry.listReadOnlyTools(); @@ -96,6 +115,7 @@ async function main(): Promise { const shutdown = async (): Promise => { telegram.stop(); stopCron?.(); + stopAlerts?.(); await registry.close(); await app.close(); repo?.close(); diff --git a/orchestrator/test/alert-scheduler.test.ts b/orchestrator/test/alert-scheduler.test.ts new file mode 100644 index 0000000..c08b695 --- /dev/null +++ b/orchestrator/test/alert-scheduler.test.ts @@ -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 { + 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); + }); +});