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