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:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user