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:
@@ -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
|
||||
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>;
|
||||
@@ -69,6 +77,7 @@ const SECRET_KEYS = new Set<keyof Config>([
|
||||
"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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const shutdown = async (): Promise<void> => {
|
||||
telegram.stop();
|
||||
stopCron?.();
|
||||
stopAlerts?.();
|
||||
await registry.close();
|
||||
await app.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