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 -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
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}`;
+6
View File
@@ -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 {
+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
// 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<typeof schema>;
@@ -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) {
@@ -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 };
+12
View File
@@ -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<void> {
});
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
+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);
});
});