feat: squelette orchestrateur TS fail-closed (v0.5.0)

Bootstrap backend Phase 1 : config zod fail-closed (refuse de démarrer
sans secret ; verrou lecture seule Portainer ; secrets masqués), logger
pino + journal d'audit, Dockerfile multi-stage non-root base épinglée,
vitest. Deps épinglées, npm audit 0 vuln, typecheck vert.

Palier de risque : reversible (aucune écriture branchée).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 01:09:02 +02:00
parent 6eda8312a6
commit 5fcb3ef18d
11 changed files with 3899 additions and 0 deletions
+9
View File
@@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [Unreleased]
## [0.5.0] — 2026-06-23 — début Phase 1 (cerveau lecture seule)
### Added
- Squelette orchestrateur TS (Node 22, ESM strict, Fastify) : `config.ts`
fail-closed (zod, verrou `assertReadOnlyPhase`, `redactedConfig`),
`audit/log.ts` (pino + journal d'exécutions), `index.ts` (bootstrap +
healthcheck interne), `Dockerfile` multi-stage (base épinglée, user non-root),
`vitest.config.ts`, `orchestrator/README.md`.
- Dépendances **épinglées**, `npm audit` à 0 vulnérabilité.
## [0.4.0] — 2026-06-23 — fin Phase 0 (socle) ## [0.4.0] — 2026-06-23 — fin Phase 0 (socle)
### Added ### Added
- `infra/docker-compose.yml` : stack CHLOVA — Ollama (proxy cloud, interne + - `infra/docker-compose.yml` : stack CHLOVA — Ollama (proxy cloud, interne +
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
coverage
.vitest
test
*.log
.env
.env.*
!.env.example
+23
View File
@@ -0,0 +1,23 @@
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest).
# TODO épingler le digest (node:22.14-bookworm-slim@sha256:...) avant déploiement réel.
FROM node:22.14-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22.14-bookworm-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root.
RUN mkdir -p /app/data && chown -R node:node /app
USER node
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "dist/index.js"]
+37
View File
@@ -0,0 +1,37 @@
# orchestrator — backend CHLOVA
Le cerveau : LLM en boucle tool-calling. **Phase 1 = lecture seule.** Seule
surface exposée de la stack (voir `../CLAUDE.md`, `../docs/architecture.md`).
## Stack
Node 22, TypeScript (ESM, NodeNext, strict), Fastify, MCP SDK officiel, pino
(audit), zod (config), Vitest (tests). Toutes deps **épinglées** ; `npm audit`
doit rester à 0 vuln.
## Démarrage (dev)
```bash
cp ../.env.example ../.env # renseigner les secrets (jamais commités)
npm install
npm run dev # tsx watch
npm run typecheck
npm test
```
## Modules
| Fichier | Rôle |
|---|---|
| `src/config.ts` | Config **fail-closed** (zod). Refuse de démarrer si secret manquant. `assertReadOnlyPhase()` verrouille la lecture seule P1. `redactedConfig()` masque les secrets. |
| `src/audit/log.ts` | Logger pino + journal d'audit des exécutions d'outils. |
| `src/index.ts` | Bootstrap : config → verrou RO → logger → (P1) MCP + agent + Telegram. Healthcheck interne. |
| `src/llm/` | Client Ollama + boucle agent (v0.6.0). |
| `src/mcp/` | Registry MCP + readonly-filter (v0.7.0). |
| `src/gatekeeper/` | Paliers de risque + table assets (interfaces P1, câblé P2). |
| `src/surfaces/` | Surface Telegram (v0.8.0). |
## Sécurité
- Secrets par référence uniquement, jamais loggés (`redactedConfig` + redact pino).
- Phase 1 : `PORTAINER_READ_ONLY=false` fait **échouer** le démarrage.
- Aucun port publié (Telegram long-polling) ; Fastify n'écoute qu'en interne.
## Palier de risque
`reversible` (lecture seule). Aucune capacité d'écriture branchée en Phase 1.
+3548
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "chlova-orchestrator",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "CHLOVA — cerveau LLM en boucle tool-calling (Phase 1 : lecture seule)",
"engines": {
"node": ">=22"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.29.0",
"fastify": "5.8.5",
"pino": "10.3.1",
"zod": "3.24.1"
},
"devDependencies": {
"@types/node": "22.13.1",
"tsx": "4.22.4",
"typescript": "5.7.3",
"vitest": "4.1.9"
}
}
+54
View File
@@ -0,0 +1,54 @@
import { pino } from "pino";
import type { Logger } from "pino";
/**
* Journal d'audit CHLOVA.
*
* Règle (docs/security.md) : toute opération mutante est audit-loggée. En
* Phase 1 (lecture seule), on trace AUSSI chaque exécution d'outil — la
* traçabilité doit exister avant d'ouvrir l'écriture en Phase 2.
*
* Les secrets ne transitent jamais ici : on logge des références/identifiants,
* pas des valeurs sensibles.
*/
export type RiskTier = "reversible" | "privileged";
export interface ToolExecutionRecord {
/** Serveur MCP source (ex. "n8n", "portainer"). */
server: string;
/** Nom de l'outil MCP appelé. */
tool: string;
/** Palier de risque résolu pour cet appel. */
riskTier: RiskTier;
/** Lecture seule (readOnlyHint) ? */
readOnly: boolean;
/** Identité de l'appelant (ex. id utilisateur Telegram). */
actor: string;
/** Résultat de l'exécution. */
outcome: "ok" | "error" | "blocked";
/** Durée en ms. */
durationMs?: number;
/** Message d'erreur éventuel (jamais de secret). */
error?: string;
}
export function createLogger(level: string): Logger {
return pino({
level,
base: { app: "chlova" },
redact: {
// Filet de sécurité : masque tout champ qui ressemble à un secret.
paths: ["*.token", "*.apiKey", "*.authorization", "*.password", "*.key"],
censor: "[REDACTED]",
},
});
}
/** Logge une exécution d'outil dans le journal d'audit. */
export function auditToolExecution(
logger: Logger,
rec: ToolExecutionRecord,
): void {
logger.info({ audit: "tool_execution", ...rec }, "tool execution");
}
+118
View File
@@ -0,0 +1,118 @@
import { z } from "zod";
/**
* Configuration CHLOVA — fail-closed.
*
* Tous les secrets sont fournis par l'environnement (jamais en dur, jamais
* commités — voir ../../.env.example et docs/security.md). Le process REFUSE de
* démarrer si une variable requise manque : pas de démarrage en mode dégradé.
*
* L'agent ne voit jamais ces valeurs : `redactedConfig()` produit une vue sûre
* pour les logs (secrets remplacés par des références).
*/
const nonEmpty = z.string().min(1);
const schema = z.object({
env: z.enum(["development", "production"]).default("development"),
logLevel: z
.enum(["fatal", "error", "warn", "info", "debug", "trace"])
.default("info"),
// Ollama (proxy cloud)
ollamaBaseUrl: z.string().url(),
ollamaApiKey: nonEmpty, // SECRET
ollamaModel: nonEmpty,
// MCP n8n
mcpN8nUrl: z.string().url(),
mcpN8nAuthToken: nonEmpty, // SECRET
// MCP Portainer
mcpPortainerUrl: z.string().url(),
portainerMcpAuthToken: nonEmpty, // SECRET
// Verrou Phase 1 : lecture seule. `false` est refusé tant que la Phase 2
// n'est pas explicitement activée (voir assertReadOnlyPhase()).
portainerReadOnly: z
.string()
.default("true")
.transform((v) => v.toLowerCase() !== "false"),
// Surface Telegram
telegramBotToken: nonEmpty, // SECRET
telegramAllowedUserIds: z
.string()
.default("")
.transform((v) =>
v
.split(",")
.map((s) => s.trim())
.filter(Boolean),
),
// Backend
dbPath: z.string().default("./data/chlova.db"),
});
export type Config = z.infer<typeof schema>;
/** Clés considérées comme secrètes : jamais loggées en clair. */
const SECRET_KEYS = new Set<keyof Config>([
"ollamaApiKey",
"mcpN8nAuthToken",
"portainerMcpAuthToken",
"telegramBotToken",
]);
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
const parsed = schema.safeParse({
env: env.CHLOVA_ENV,
logLevel: env.CHLOVA_LOG_LEVEL,
ollamaBaseUrl: env.OLLAMA_BASE_URL,
ollamaApiKey: env.OLLAMA_API_KEY,
ollamaModel: env.OLLAMA_MODEL,
mcpN8nUrl: env.MCP_N8N_URL,
mcpN8nAuthToken: env.MCP_N8N_AUTH_TOKEN,
mcpPortainerUrl: env.MCP_PORTAINER_URL,
portainerMcpAuthToken: env.PORTAINER_MCP_AUTH_TOKEN,
portainerReadOnly: env.PORTAINER_READ_ONLY,
telegramBotToken: env.TELEGRAM_BOT_TOKEN,
telegramAllowedUserIds: env.TELEGRAM_ALLOWED_USER_IDS,
dbPath: env.CHLOVA_DB_PATH,
});
if (!parsed.success) {
const missing = parsed.error.issues
.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
.join("\n ");
// Fail-closed : on ne démarre pas sans config complète.
throw new Error(
`Configuration CHLOVA invalide (fail-closed). Corrige .env :\n ${missing}`,
);
}
return parsed.data;
}
/**
* Verrou Phase 1 : la lecture seule Portainer est obligatoire. Tant que la
* Phase 2 (écriture) n'est pas activée, démarrer avec PORTAINER_READ_ONLY=false
* est une erreur. Empêche un glissement silencieux vers l'écriture.
*/
export function assertReadOnlyPhase(cfg: Config): void {
if (!cfg.portainerReadOnly) {
throw new Error(
"PORTAINER_READ_ONLY=false interdit en Phase 1 (lecture seule). " +
"L'écriture est une capacité Phase 2 sous revue. Voir docs/security.md.",
);
}
}
/** Vue de la config sûre pour les logs : secrets masqués. */
export function redactedConfig(cfg: Config): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(cfg)) {
out[k] = SECRET_KEYS.has(k as keyof Config) ? "[REDACTED]" : v;
}
return out;
}
+39
View File
@@ -0,0 +1,39 @@
import Fastify from "fastify";
import { loadConfig, assertReadOnlyPhase, redactedConfig } from "./config.js";
import { createLogger } from "./audit/log.js";
/**
* Bootstrap du backend CHLOVA (Phase 1 : cerveau lecture seule).
*
* Séquence fail-closed : config valide → verrou lecture seule → logger → (Phase 1)
* registry MCP read-only + boucle agent + surface Telegram.
*
* Aucun port n'est PUBLIÉ (Telegram en long-polling). Fastify n'écoute qu'en
* interne pour le healthcheck du conteneur.
*/
async function main(): Promise<void> {
const cfg = loadConfig();
assertReadOnlyPhase(cfg); // refuse de démarrer si l'écriture serait possible
const logger = createLogger(cfg.logLevel);
logger.info({ config: redactedConfig(cfg) }, "CHLOVA backend démarrage");
const app = Fastify({ loggerInstance: logger });
app.get("/health", async () => ({ status: "ok", phase: "1-readonly" }));
// ── Câblé dans les tâches suivantes (Phase 1) ──────────────────────────
// v0.6.0 : client Ollama + boucle agent (comprendre → outil → répondre)
// v0.7.0 : registry MCP + readonly-filter (n8n + Portainer read-only)
// v0.8.0 : surface Telegram (long-polling)
const port = 8080; // interne uniquement (jamais publié en P1)
await app.listen({ host: "0.0.0.0", port });
logger.info({ port }, "healthcheck interne prêt");
}
main().catch((err: unknown) => {
// Fail-closed : toute erreur de boot stoppe le process.
console.error(err instanceof Error ? err.message : err);
process.exit(1);
});
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
environment: "node",
coverage: {
include: ["src/**/*.ts"],
reporter: ["text", "lcov"],
},
},
});