feat: outil propose_asset + auto-extension exposée, fin Phase 5 v1 (v0.27.0)

Outil local sanctionné chlova.propose_asset : l'agent propose un asset →
write+commit+version+doc → need-review (privilégié = BLOQUÉ). Notion
ToolSpec.sanctioned (autorisé par gatekeeper, audité). Flag
CHLOVA_AUTOEXT_ENABLED (off défaut) + CHLOVA_REPO_ROOT. Prompt impose un
palier honnête. 75 tests, 0 vuln, compose OK.

Palier de risque : privilégié (l'agent écrit+commit) — derrière flag +
Phase 2 ; l'asset produit n'est jamais exécuté, il reste sous review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 06:38:45 +02:00
parent bc61434f7c
commit 2bfa58f440
11 changed files with 213 additions and 4 deletions
+7
View File
@@ -54,6 +54,13 @@ CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT
CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement) 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. # 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) 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). # Domaine public derrière Traefik (label compose).
CHLOVA_DOMAIN=chlova.example.com CHLOVA_DOMAIN=chlova.example.com
# Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI. # Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI.
+12
View File
@@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [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 ## [0.26.0] — 2026-06-23
### Added ### Added
- `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais - `src/autoext/git-committer.ts` : `GitCommitter` — commits ciblés (jamais
+16 -3
View File
@@ -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`). `workflows-n8n/chlova-alerts.v1.0.0.json` (doc : `docs/assets/workflow-chlova-alerts.md`).
Le payload ne contient aucun secret. Le payload ne contient aucun secret.
## Reste à faire (Phase 4+) ## Auto-extension (Phase 5 — implémenté)
- Auto-extension : CHLOVA génère commit + version + doc d'un asset **avant** Quand aucune capacité n'existe, l'agent appelle l'outil **sanctionné**
de le passer en need-review (Phase 4). `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).
+5
View File
@@ -87,11 +87,16 @@ services:
CHLOVA_ENV: ${CHLOVA_ENV:-production} CHLOVA_ENV: ${CHLOVA_ENV:-production}
CHLOVA_PHASE: ${CHLOVA_PHASE:-1} # 1 = lecture seule (défaut) ; 2 = écriture sous review 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 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} OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://ollama:11434}
MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n MCP_N8N_URL: ${MCP_N8N_URL} # endpoint MCP natif de n8n
MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000} MCP_PORTAINER_URL: ${MCP_PORTAINER_URL:-http://mcp-portainer:3000}
volumes: volumes:
- chlova-data:/app/data # SQLite (table assets, P2+) - 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: depends_on:
- ollama - ollama
- mcp-portainer - mcp-portainer
+7 -1
View File
@@ -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 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 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 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 { export function buildSystemPrompt(phase: 1 | 2): string {
return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`; return `${COMMON}\n\n${phase === 2 ? PHASE_2 : PHASE_1}`;
+6
View File
@@ -17,6 +17,12 @@ export interface ToolSpec {
readOnly: boolean; readOnly: boolean;
/** Palier de risque résolu (voir docs/risk-tiers.md). */ /** Palier de risque résolu (voir docs/risk-tiers.md). */
riskTier: RiskTier; 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 { export interface ToolHandle {
+67
View File
@@ -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<string, unknown>): Promise<string> {
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.")
);
},
};
}
+10
View File
@@ -80,6 +80,14 @@ const schema = z.object({
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin ), // 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). // Racine du SPA buildé, servi same-origin (vide = ne sert pas de SPA).
webRoot: z.string().optional(), 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<typeof schema>; export type Config = z.infer<typeof schema>;
@@ -119,6 +127,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
jwtSecret: env.CHLOVA_JWT_SECRET, jwtSecret: env.CHLOVA_JWT_SECRET,
webOrigin: env.CHLOVA_WEB_ORIGIN, webOrigin: env.CHLOVA_WEB_ORIGIN,
webRoot: env.CHLOVA_WEB_ROOT, webRoot: env.CHLOVA_WEB_ROOT,
autoextEnabled: env.CHLOVA_AUTOEXT_ENABLED,
repoRoot: env.CHLOVA_REPO_ROOT,
}); });
if (!parsed.success) { if (!parsed.success) {
@@ -48,6 +48,11 @@ export class Gatekeeper {
* incrément du compteur d'exécution). * incrément du compteur d'exécution).
*/ */
authorizeTool(spec: ToolSpec): { allowed: boolean; reason?: string } { 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. // Lecture seule : pas d'asset, pas de review.
if (spec.riskTier === "reversible" && spec.readOnly) { if (spec.riskTier === "reversible" && spec.readOnly) {
return { allowed: true }; return { allowed: true };
+12
View File
@@ -15,6 +15,9 @@ import { isCommand, handleReviewCommand } from "./surfaces/commands.js";
import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js"; import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
import { startAlertScheduler } from "./alerts/scheduler.js"; import { startAlertScheduler } from "./alerts/scheduler.js";
import type { AlertSender } from "./alerts/types.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 type { Guard, ToolHandle } from "./agent/types.js";
import { ChatService } from "./agent/chat-service.js"; import { ChatService } from "./agent/chat-service.js";
import { buildSystemPrompt } from "./agent/system-prompt.js"; import { buildSystemPrompt } from "./agent/system-prompt.js";
@@ -80,6 +83,15 @@ async function main(): Promise<void> {
}); });
guard = new GatekeeperGuard(gatekeeper); guard = new GatekeeperGuard(gatekeeper);
tools = await registry.listAllTools(); 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); review = new ReviewService(repo, logger);
stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire stopCron = startExpiryCron(repo, logger); // PROVISOIRE → BLOQUÉ, horaire
stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1 stopAlerts = startAlertScheduler(repo, alerts, logger); // digest quotidien + J-1
+66
View File
@@ -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);
});
});