diff --git a/.env.example b/.env.example index 7034c77..8b4bc06 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,13 @@ CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement) # Racine du SPA buildé servi same-origin. Défaut image = /app/web. Vide = pas de SPA. CHLOVA_WEB_ROOT= # laisser vide en conteneur (défaut /app/web) + +# ── Auto-extension (Phase 5) ─────────────────────────────────────────── +# Si true, l'agent peut créer des assets en need-review (écrit + commit + +# versionne + documente). Désactivé par défaut (fail-safe). Requiert un dépôt +# git monté dans le conteneur à CHLOVA_REPO_ROOT. +CHLOVA_AUTOEXT_ENABLED=false +CHLOVA_REPO_ROOT=. # chemin du dépôt (working copy GitOps) # Domaine public derrière Traefik (label compose). CHLOVA_DOMAIN=chlova.example.com # Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ede0f0..c7d517e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [Unreleased] +## [0.27.0] — 2026-06-23 — fin Phase 5 (auto-extension v1) +### Added +- Outil local **`chlova.propose_asset`** (`src/autoext/tool.ts`) exposé à l'agent : + propose un asset → write+commit+doc → need-review. Notion d'outil **sanctionné** + (`ToolSpec.sanctioned`) autorisé par le gatekeeper mais audité. +- Config `CHLOVA_AUTOEXT_ENABLED` (défaut false) + `CHLOVA_REPO_ROOT`. Câblage + Phase 2 + flag. Prompt système Phase 2 mis à jour (palier honnête imposé). +- Tests (2) : gatekeeper autorise un outil sanctionné ; tool propose. 75 tests. +### Changed +- `.env.example` + compose : flag autoext + montage dépôt (commenté). + `docs/need-review.md` : section auto-extension. Compose revalidé, 0 vuln. + ## [0.26.0] — 2026-06-23 ### Added - `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais diff --git a/docs/need-review.md b/docs/need-review.md index 2b89f62..eeae984 100644 --- a/docs/need-review.md +++ b/docs/need-review.md @@ -62,6 +62,19 @@ casse jamais l'agent. Le mail est envoyé par le workflow `workflows-n8n/chlova-alerts.v1.0.0.json` (doc : `docs/assets/workflow-chlova-alerts.md`). Le payload ne contient aucun secret. -## Reste à faire (Phase 4+) -- Auto-extension : CHLOVA génère commit + version + doc d'un asset **avant** - de le passer en need-review (Phase 4). +## Auto-extension (Phase 5 — implémenté) +Quand aucune capacité n'existe, l'agent appelle l'outil **sanctionné** +`chlova.propose_asset` (`src/autoext/`) qui : +1. écrit l'artefact (workflow/outil) + sa doc (gabarit) dans le dépôt ; +2. **commit + versionne** (commit ciblé, jamais `git add -A`) ; +3. enregistre l'asset en need-review (privilégié → **BLOQUÉ**, aucun sursis) ; +4. émet l'alerte `asset_created` (version + commit + doc). + +L'asset n'est **jamais exécuté** par ce canal : il attend la review (/approve). +Un outil sanctionné est autorisé par le gatekeeper mais **audité** ; il ne touche +pas l'infra. Désactivé par défaut (`CHLOVA_AUTOEXT_ENABLED=false`) ; requiert un +dépôt git monté (`CHLOVA_REPO_ROOT`). Le LLM ne peut pas sous-classer un asset +privilégié (palier honnête imposé par le prompt + non négociable côté review). + +## Reste à faire (Phase 6+) +- Voix : STT + wake-word + TTS dans l'UI (API déjà prête). diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index df243cc..bdf5fd9 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -87,11 +87,16 @@ services: CHLOVA_ENV: ${CHLOVA_ENV:-production} CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule (défaut) ; 2 = écriture sous review ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} # Phase 3 : vide = alertes log-only + CHLOVA_AUTOEXT_ENABLED: ${CHLOVA_AUTOEXT_ENABLED:-false} # Phase 5 : off par défaut + CHLOVA_REPO_ROOT: ${CHLOVA_REPO_ROOT:-/app/repo} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434} MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000} volumes: - chlova-data:/app/data # SQLite (table assets, P2+) + # Auto-extension (Phase 5, off par défaut) : monter le dépôt git ici pour + # que CHLOVA puisse committer les assets créés. + # - /srv/chlova-repo:/app/repo depends_on: - ollama - mcp-portainer diff --git a/orchestrator/src/agent/system-prompt.ts b/orchestrator/src/agent/system-prompt.ts index 167ac2b..e1949fb 100644 --- a/orchestrator/src/agent/system-prompt.ts +++ b/orchestrator/src/agent/system-prompt.ts @@ -24,7 +24,13 @@ const PHASE_2 = `PHASE ACTUELLE : ÉCRITURE SOUS REVIEW. Tu peux proposer des ac mutantes (déploiement, modification). Toute action privilégiée est BLOQUÉE par le gatekeeper jusqu'à validation humaine : à la première tentative, elle est refusée et mise en attente de review. N'affirme jamais qu'une action mutante a réussi tant -que l'outil ne l'a pas confirmée. Les lectures restent libres.`; +que l'outil ne l'a pas confirmée. Les lectures restent libres. + +Si AUCUNE capacité existante ne convient et que l'outil chlova.propose_asset est +disponible, tu peux proposer un nouvel asset (workflow n8n ou outil) : il sera +écrit, versionné, documenté et mis EN REVIEW (un asset privilégié reste bloqué +jusqu'à validation humaine). Classe honnêtement le palier de risque ; ne sous- +estime jamais un asset privilégié pour contourner la review.`; export function buildSystemPrompt(phase: 1 | 2): string { return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`; diff --git a/orchestrator/src/agent/types.ts b/orchestrator/src/agent/types.ts index 28c1fa8..223d5bd 100644 --- a/orchestrator/src/agent/types.ts +++ b/orchestrator/src/agent/types.ts @@ -17,6 +17,12 @@ export interface ToolSpec { readOnly: boolean; /** Palier de risque résolu (voir docs/risk-tiers.md). */ riskTier: RiskTier; + /** + * Outil interne SANCTIONNÉ de CHLOVA (ex. propose_asset). Ne touche pas + * l'infra : il STAGE des propositions reviewables (l'asset produit reste gated). + * Autorisé par le gatekeeper mais toujours audité. Jamais issu de MCP. + */ + sanctioned?: boolean; } export interface ToolHandle { diff --git a/orchestrator/src/autoext/tool.ts b/orchestrator/src/autoext/tool.ts new file mode 100644 index 0000000..f94473a --- /dev/null +++ b/orchestrator/src/autoext/tool.ts @@ -0,0 +1,67 @@ +import type { ToolHandle } from "../agent/types.js"; +import type { AutoExtensionService } from "./auto-extension.js"; +import type { AssetDraft, AutoAssetType } from "./artifact-writer.js"; + +/** + * Outil local `chlova.propose_asset` exposé à l'agent (Phase 5). + * + * Quand aucune capacité existante ne suffit, l'agent propose un nouvel asset + * (workflow n8n ou outil). L'outil écrit + commit + versionne + documente, puis + * enregistre l'asset en NEED-REVIEW. Il n'EXÉCUTE jamais l'asset. Sanctionné + * (autorisé par le gatekeeper) mais audité. + */ +export function buildProposeAssetTool(service: AutoExtensionService): ToolHandle { + return { + spec: { + name: "chlova.propose_asset", + description: + "Propose un nouvel asset (workflow n8n ou outil) quand rien d'existant " + + "ne convient. Écrit + commit + versionne + documente, puis met en " + + "need-review (un asset privilégié reste BLOQUÉ jusqu'à validation humaine). " + + "N'exécute jamais l'asset.", + parameters: { + type: "object", + required: ["type", "name", "version", "riskTier", "summary", "content"], + properties: { + type: { type: "string", enum: ["workflow-n8n", "tool"] }, + name: { type: "string", description: "Nom lisible de l'asset." }, + version: { type: "string", description: "SemVer, ex. 1.0.0." }, + riskTier: { + type: "string", + enum: ["reversible", "privileged"], + description: + "privileged si l'asset déploie/supprime/accède aux secrets/exécute ; sinon reversible.", + }, + summary: { type: "string", description: "Rôle de l'asset (1-3 phrases)." }, + content: { + type: "string", + description: "Contenu : JSON du workflow n8n, ou définition de l'outil.", + }, + }, + }, + server: "chlova", + readOnly: false, + riskTier: "privileged", + sanctioned: true, + }, + async execute(args: Record): Promise { + const draft: AssetDraft = { + type: args.type as AutoAssetType, + name: String(args.name ?? ""), + version: String(args.version ?? ""), + riskTier: args.riskTier === "reversible" ? "reversible" : "privileged", + summary: String(args.summary ?? ""), + content: String(args.content ?? ""), + }; + const res = await service.propose(draft); + return ( + `Asset créé en need-review : ${res.asset.id}\n` + + `Statut : ${res.asset.status} (palier ${res.asset.riskTier})\n` + + `Commit : ${res.commit} · Doc : ${res.docPath}\n` + + (res.asset.status === "bloqué" + ? "Privilégié → BLOQUÉ jusqu'à validation humaine (/approve)." + : "Provisoire → exécutable, sursis 7 jours.") + ); + }, + }; +} diff --git a/orchestrator/src/config.ts b/orchestrator/src/config.ts index 510809f..c7361c3 100644 --- a/orchestrator/src/config.ts +++ b/orchestrator/src/config.ts @@ -80,6 +80,14 @@ const schema = z.object({ ), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin // Racine du SPA buildé, servi same-origin (vide = ne sert pas de SPA). webRoot: z.string().optional(), + + // Auto-extension (Phase 5) : l'agent peut créer des assets en need-review. + // Désactivé par défaut (fail-safe). Requiert un dépôt git à la racine. + autoextEnabled: z + .string() + .default("false") + .transform((v) => v.toLowerCase() === "true"), + repoRoot: z.string().default("."), }); export type Config = z.infer; @@ -119,6 +127,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { jwtSecret: env.CHLOVA_JWT_SECRET, webOrigin: env.CHLOVA_WEB_ORIGIN, webRoot: env.CHLOVA_WEB_ROOT, + autoextEnabled: env.CHLOVA_AUTOEXT_ENABLED, + repoRoot: env.CHLOVA_REPO_ROOT, }); if (!parsed.success) { diff --git a/orchestrator/src/gatekeeper/gatekeeper.ts b/orchestrator/src/gatekeeper/gatekeeper.ts index df477bc..8db863d 100644 --- a/orchestrator/src/gatekeeper/gatekeeper.ts +++ b/orchestrator/src/gatekeeper/gatekeeper.ts @@ -48,6 +48,11 @@ export class Gatekeeper { * incrément du compteur d'exécution). */ authorizeTool(spec: ToolSpec): { allowed: boolean; reason?: string } { + // Outil interne sanctionné (ex. propose_asset) : canal contrôlé qui ne fait + // que stager des propositions reviewables (l'asset produit reste gated). + if (spec.sanctioned) { + return { allowed: true }; + } // Lecture seule : pas d'asset, pas de review. if (spec.riskTier === "reversible" && spec.readOnly) { return { allowed: true }; diff --git a/orchestrator/src/index.ts b/orchestrator/src/index.ts index 45b62bc..509ab22 100644 --- a/orchestrator/src/index.ts +++ b/orchestrator/src/index.ts @@ -15,6 +15,9 @@ 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 { GitCommitter } from "./autoext/git-committer.js"; +import { AutoExtensionService } from "./autoext/auto-extension.js"; +import { buildProposeAssetTool } from "./autoext/tool.js"; import type { Guard, ToolHandle } from "./agent/types.js"; import { ChatService } from "./agent/chat-service.js"; import { buildSystemPrompt } from "./agent/system-prompt.js"; @@ -80,6 +83,15 @@ async function main(): Promise { }); guard = new GatekeeperGuard(gatekeeper); tools = await registry.listAllTools(); + + // Auto-extension (Phase 5) : ajoute l'outil sanctionné propose_asset. + if (cfg.autoextEnabled) { + const git = new GitCommitter(cfg.repoRoot); + const autoext = new AutoExtensionService(repo, git, alerts, logger, cfg.repoRoot); + tools = [...tools, buildProposeAssetTool(autoext)]; + logger.warn({ repoRoot: cfg.repoRoot }, "auto-extension ACTIVÉE (propose_asset exposé)"); + } + review = new ReviewService(repo, logger); stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1 diff --git a/orchestrator/test/autoext-tool.test.ts b/orchestrator/test/autoext-tool.test.ts new file mode 100644 index 0000000..7592f16 --- /dev/null +++ b/orchestrator/test/autoext-tool.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AssetRepository } from "../src/gatekeeper/repository.js"; +import { Gatekeeper } from "../src/gatekeeper/gatekeeper.js"; +import { GitCommitter } from "../src/autoext/git-committer.js"; +import { AutoExtensionService } from "../src/autoext/auto-extension.js"; +import { buildProposeAssetTool } from "../src/autoext/tool.js"; +import { NullAlertSender } from "../src/alerts/sender.js"; +import { createLogger } from "../src/audit/log.js"; +import type { ToolSpec } from "../src/agent/types.js"; + +const exec = promisify(execFile); +const log = createLogger("silent"); + +describe("gatekeeper autorise un outil sanctionné", () => { + it("propose_asset (privilégié + sanctioned) est autorisé sans review", () => { + const repo = new AssetRepository(":memory:"); + const gk = new Gatekeeper(repo, log); + const spec: ToolSpec = { + name: "chlova.propose_asset", + description: "", + parameters: {}, + server: "chlova", + readOnly: false, + riskTier: "privileged", + sanctioned: true, + }; + expect(gk.authorizeTool(spec).allowed).toBe(true); + repo.close(); + }); +}); + +describe("propose_asset tool", () => { + let root: string; + let repo: AssetRepository; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "chlova-tool-")); + await exec("git", ["init", "-q"], { cwd: root }); + await exec("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-q", "-m", "init"], { cwd: root }); + repo = new AssetRepository(":memory:"); + }); + afterEach(async () => { + repo.close(); + await rm(root, { recursive: true, force: true }); + }); + + it("exécute la proposition et renvoie un résumé", async () => { + const svc = new AutoExtensionService(repo, new GitCommitter(root), new NullAlertSender(log), log, root); + const tool = buildProposeAssetTool(svc); + expect(tool.spec.sanctioned).toBe(true); + const out = await tool.execute({ + type: "tool", + name: "Ping Host", + version: "1.0.0", + riskTier: "reversible", + summary: "ping", + content: "{}", + }); + expect(out).toContain("need-review"); + expect(repo.listByStatus("provisoire")).toHaveLength(1); + }); +});