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:
@@ -6,6 +6,15 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
||||
|
||||
## [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)
|
||||
### Added
|
||||
- `infra/docker-compose.yml` : stack CHLOVA — Ollama (proxy cloud, interne +
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
.vitest
|
||||
test
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
Generated
+3548
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user