test: gatekeeper + readonly-filter + config, interfaces need-review (v0.9.0)

Fin Phase 1. 22 tests verts : barrière readonly-filter (fail-safe),
ReadOnlyGuard, paliers de risque + sursis, invariant anti-escalade,
config fail-closed + masquage secrets. Interfaces du cycle need-review
posées pour la Phase 2 (Asset, canExecute) sans câblage runtime. Split
tsconfig typecheck/build.

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:18:02 +02:00
parent c6309fd9a5
commit 1cce8c9db6
8 changed files with 316 additions and 5 deletions
+98
View File
@@ -0,0 +1,98 @@
import type { RiskTier } from "../audit/log.js";
/**
* Modèle d'asset & cycle "need review" — INTERFACES posées pour la Phase 2.
*
* Non câblé au runtime en Phase 1 (aucune écriture). On fige ici le contrat et
* les invariants non négociables (paliers de risque, sursis, anti-escalade) +
* leurs tests, pour que la Phase 2 les implémente sans dériver des règles.
* Voir docs/risk-tiers.md.
*/
export type AssetType = "workflow-n8n" | "stack-portainer" | "tool" | "image";
export type AssetStatus = "provisoire" | "approuvé" | "refusé" | "bloqué";
/** Durée du sursis PROVISOIRE pour un asset réversible. */
export const PROVISIONAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** Reflète la table d'assets décrite dans CLAUDE.md. */
export interface Asset {
id: string;
type: AssetType;
version: string; // semver de l'asset
riskTier: RiskTier;
status: AssetStatus;
createdAt: number; // epoch ms
expiresAt: number | null; // null si pas de sursis (privilégié)
execCount: number;
commitLink: string | null;
docLink: string | null;
}
export interface CreateAssetInput {
id: string;
type: AssetType;
version: string;
riskTier: RiskTier;
now?: number;
commitLink?: string;
docLink?: string;
}
/**
* Crée un asset en appliquant la RÈGLE DE SURSIS (non négociable) :
* - `reversible` → PROVISOIRE, exécutable, expire à +7 jours ;
* - `privileged` → BLOQUÉ immédiatement, AUCUN sursis, pas d'expiration.
*/
export function createAsset(input: CreateAssetInput): Asset {
const now = input.now ?? Date.now();
const reversible = input.riskTier === "reversible";
return {
id: input.id,
type: input.type,
version: input.version,
riskTier: input.riskTier,
status: reversible ? "provisoire" : "bloqué",
createdAt: now,
expiresAt: reversible ? now + PROVISIONAL_TTL_MS : null,
execCount: 0,
commitLink: input.commitLink ?? null,
docLink: input.docLink ?? null,
};
}
export class EscalationError extends Error {}
/**
* Invariant anti-escalade : un asset `privileged` ne peut JAMAIS être reclassé
* `reversible` (le LLM ne doit pas pouvoir contourner la review en abaissant le
* palier). Tout autre changement est permis (durcissement autorisé).
*/
export function assertNoEscalation(current: RiskTier, next: RiskTier): void {
if (current === "privileged" && next === "reversible") {
throw new EscalationError(
"Reclassement interdit : un asset privilégié ne peut pas devenir réversible.",
);
}
}
/**
* GATEKEEPER (Phase 2) : décide si un asset peut s'exécuter MAINTENANT.
* Posé ici comme référence ; non appelé en Phase 1.
*/
export function canExecute(asset: Asset, now = Date.now()): { ok: boolean; reason?: string } {
switch (asset.status) {
case "approuvé":
return { ok: true };
case "refusé":
return { ok: false, reason: "asset refusé" };
case "bloqué":
return { ok: false, reason: "asset bloqué (review requise)" };
case "provisoire":
if (asset.expiresAt !== null && now > asset.expiresAt) {
return { ok: false, reason: "sursis expiré → bloqué" };
}
return { ok: true };
}
}