Date : 15-02-2026 Status : IMPLEMENTEE Auditeur : Claude Opus 4.6 Projet : ulias-org (packages/server/src/) Methode : Lecture integrale de tous les fichiers source, tracage des chemins d'execution, verification croisee avec conf.prod.gouroubleu.yml
Le projet ulias-org est globalement bien structure : architecture modulaire (director/interpreter/agent-runner/briefing/db), separation des responsabilites, gestion d'erreurs non-bloquante pour la DB. Cependant, l'audit revele 3 bugs certains, 5 bugs probables, 4 risques theoriques, et 2 faux positifs parmi les findings signales lors du premier audit.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/briefing/index.ts lignes 148-151
Severite : HAUTE
const skipWords = ['passer', 'skip', '-', 'non', 'rien', 'aucun'];
if (!currentQ.required || !skipWords.includes(answer.trim().toLowerCase())) {
session.answers.set(currentQ.id, answer);
}
Analyse detaillee :
La condition !currentQ.required || !skipWords.includes(answer) se traduit par :
!required = true) : la condition est toujours true (court-circuit OR), donc la reponse est TOUJOURS enregistree, meme si l'utilisateur dit "passer". Le skip ne fonctionne jamais pour les questions optionnelles.!required = false) : la condition vaut !skipWords.includes(answer). Donc si l'utilisateur repond "passer" a une question obligatoire, la reponse n'est PAS enregistree — ce qui est correct (on ne veut pas enregistrer un skip sur une question required).Le bug : Pour les questions required: false, l'utilisateur peut dire "passer" mais sa reponse sera quand meme enregistree comme "passer" dans les answers. Le skip est mort pour les questions optionnelles.
La logique correcte serait :
const isSkip = skipWords.includes(answer.trim().toLowerCase());
if (!isSkip || currentQ.required) {
session.answers.set(currentQ.id, answer);
}
Qui signifie : "enregistre la reponse sauf si c'est un skip ET que la question n'est pas obligatoire".
Impact : Quand l'utilisateur repond "passer" ou "non" a une question de briefing optionnelle (ex: "Autres details ?"), la valeur "passer" est stockee et passee au LLM comme specification. Cela peut polluer les prompts enrichis et generer du code avec des champs "passer".
Classification : BUG CERTAIN
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/cli/bin/ulias.ts ligne 13
Severite : MOYENNE
const DEFAULT_SERVER = process.env.ULIAS_SERVER_URL || 'ws://localhost:5510/ws/cli';
Le serveur ulias-org ecoute sur le port 5515 (confirme dans index.ts ligne 17 et conf.prod.gouroubleu.yml ligne 6). Le port 5510 est celui de claude-memory (d'apres CLAUDE.md).
Impact : Le CLI ne peut pas se connecter sans specifier explicitement --server ws://localhost:5515/ws/cli ou via ULIAS_SERVER_URL. Le mode par defaut est casse.
Classification : BUG CERTAIN
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 86, 130, 222-228, 294, 351
Severite : MOYENNE
Plusieurs chemins appellent ws.send() sans verifier l'etat du WebSocket :
Chemin 1 — generateConversationTitle (ligne 86) :
ws.send(JSON.stringify({
type: 'conversation_title_updated',
conversationId: convId,
title,
tags,
}));
Cette fonction est fire-and-forget (ligne 386). Si le client se deconnecte entre le debut du traitement et la fin de la generation du titre (~30s de LLM), ws.send() crash.
Chemin 2 — generateConversationSummary (ligne 127) :
if (ws) {
ws.send(JSON.stringify({ ... }));
}
Le guard if (ws) verifie que ws est truthy mais pas que le WS est encore ouvert.
Chemin 3 — onEvent callback (ligne 294) :
const onEvent = (event: DirectorEvent) => {
ws.send(JSON.stringify(event));
Ce callback est appele pendant toute l'execution d'un objectif (potentiellement 10 iterations x 5 min = plusieurs minutes). Si le client se deconnecte pendant, chaque event enverra un ws.send sur un socket ferme.
Note sur Elysia/Bun WS : Dans Bun, ws.send() sur un WebSocket ferme lance une exception. Le catch global (ligne 426) devrait attraper l'erreur pour le handler handleWsMessage, mais les fonctions fire-and-forget (generateConversationTitle, generateConversationSummary) ont leur propre try/catch qui log mais ne nettoie pas la session.
Impact : Logs pollues par des erreurs de send apres deconnexion. Pas de data loss grace aux catch, mais nuisance operationnelle.
Classification : BUG CERTAIN (se produit a chaque deconnexion pendant un traitement)
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/client.ts ligne 112
Severite : HAUTE
const CALLBACK_BASE = process.env.CALLBACK_BASE_URL || 'http://192.168.1.12:5515';
Ce fallback pointe vers 192.168.1.12:5515 (prod-portainer). Comme le container tourne effectivement sur prod-portainer, cela devrait fonctionner pour le trafic intra-machine.
Cependant : conf.prod.gouroubleu.yml ne definit PAS CALLBACK_BASE_URL dans les env vars (seuls CONNECTORS_API_URL, GITLAB_URL, GITLAB_TOKEN sont definis). Donc le fallback hardcode est utilise en prod.
Le probleme : Si ai-orchestrator tourne sur win11 (192.168.1.30) et doit appeler le callback sur ulias-org (192.168.1.12:5515), il doit pouvoir atteindre cette IP. C'est probable que ca fonctionne (meme VLAN), mais la communication depasse le loopback.
Verification : L'env var n'est pas injectee donc le fallback est actif. Le callback semble fonctionner en pratique (sinon tout le LLM serait casse), donc le 192.168.1.12:5515 est probablement joignable depuis win11.
Classification : BUG PROBABLE — fonctionne en pratique mais fragile : tout changement d'IP ou de port cassera les callbacks sans warning.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts ligne 16
Severite : BASSE (en prod, l'env var est injectee)
const CONNECTORS_API_URL = process.env.CONNECTORS_API_URL || 'http://192.168.1.12:5400';
Le fallback est 5400, mais conf.prod.gouroubleu.yml injecte CONNECTORS_API_URL: "http://192.168.1.12:5403".
Verification : En production, l'env var est correctement injectee via smart-deploy. Le fallback 5400 ne serait utilise qu'en dev local sans .env, et dans ce cas ce serait effectivement une erreur.
Classification : BUG PROBABLE — ne touche pas la prod (env var injectee), mais piege en dev local.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/response-cache.ts lignes 23-37
Severite : BASSE
static hashKey(messages: any[], model?: string): string {
const userMsgs = messages.filter((m: any) => m.role === 'user');
const lastUser = userMsgs[userMsgs.length - 1]?.content || '';
const systemPrompt = messages.find((m: any) => m.role === 'system')?.content || '';
const raw = `${model || 'default'}::${systemPrompt.slice(0, 200)}::${lastUser}`;
let hash = 0;
for (let i = 0; i < raw.length; i++) {
const chr = raw.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0;
}
return `cache_${hash}`;
}
Problemes :
iterations === 1 dans agent-runner), ce qui est correct.Impact reel : Le cache TTL est de 5 min et la taille max est 50. Les collisions sont improbables en pratique car le systemPrompt des 200 premiers chars est generalement distinctif entre agents.
Classification : BUG PROBABLE — risque faible de collision en pratique, mais le design est fragile.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 373-376
Severite : BASSE
const conv = await db.getConversation(convId);
const prevCount = conv?.message_count || 0;
await db.updateConversation(convId, {
last_message_at: new Date().toISOString(),
message_count: prevCount + 2,
Pattern read-then-write classique. Si deux messages arrivent quasi-simultanement (par ex. via deux clients WS sur la meme conversation), prevCount peut etre lu a la meme valeur et le deuxieme ecrase le compteur du premier.
Impact : En mono-utilisateur pratique, le risque est tres faible. Le message_count est utilise pour declencher l'auto-titre (a 0) et l'auto-resume (tous les 10). Un increment perdu ne causerait qu'un retard dans le declenchement du resume.
Classification : BUG PROBABLE — race condition reelle, impact minimal en mono-utilisateur.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/notifications/index.ts
Severite : BASSE
La classe NotificationService est bien definie avec des methodes objectiveCompleted, objectiveFailed, decisionNeeded, proactiveAlert. Cependant, elle n'est importee nulle part dans le code du serveur.
$ grep -r "NotificationService" packages/server/src/
→ Seulement dans notifications/index.ts
Le serveur envoie des events via WS directement dans handleWsMessage et des notifications via toolsClient.notify() dans l'agent-runner. Le service de notification centralise est du code mort.
Classification : BUG PROBABLE — code mort, pas de regression, mais indique une architecture inachevee.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 775-788
Severite : BASSE
.get('/api/agents', async ({ headers }) => {
const apiKey = headers['x-api-key'];
const agents = getAllAgents();
return Object.entries(agents).map(([name, a]) => ({
name,
team: a.team,
description: a.description,
model: a.model,
prompt: a.prompt, // ← Prompt complet expose
defaultPrompt: getDefaultPrompt(name),
hasOverride: hasPromptOverride(name),
requiresApproval: a.requiresApproval,
}));
})
Le header apiKey est lu mais jamais verifie. getAllAgents() est appele inconditionnellement. N'importe qui peut GET /api/agents et obtenir tous les prompts systeme.
Impact reel : Les prompts ne contiennent pas de secrets (pas de tokens, pas de passwords). Ils contiennent des informations d'architecture (IPs internes, noms de machines, ports) qui sont dans les prompts des agents. Cependant :
private: true (IP whitelist) d'apres le pattern habituel de l'infra.Verification : conf.prod.gouroubleu.yml ne specifie pas private: true dans la section nginx. Donc le endpoint est public sur Internet si quelqu'un connait le domaine ulias-org.33800.nowhere84.com.
Classification : RISQUE THEORIQUE — les prompts contiennent des IPs internes et des noms de machines mais pas de credentials. Un attaquant externe aurait besoin de connaitre le domaine exact.
Fichier : /stock_8to/33800-stack/projects/ulias-org/conf.prod.gouroubleu.yml ligne 10
Severite : MOYENNE
env:
GITLAB_TOKEN: "glpat-yaowLwWBJhXfzJEC8UBC"
Le token GitLab personnel est en clair dans le fichier de configuration. Ce fichier est versionne dans le depot GitLab ulias-org.
Verification : Ce token apparait aussi dans CLAUDE.md. Il est utilise par les agents pour les operations GitLab (create project, create file, etc.).
Impact : Toute personne ayant acces au repo GitLab a le token admin. C'est le meme token pour tout.
Note : Ce token est deja connu dans CLAUDE.md et utilise partout dans l'infra. Le risque est surtout d'exposition accidentelle si le repo est rendu public.
Classification : RISQUE THEORIQUE — dans un contexte prive (GitLab self-hosted, single user), le risque est limite. Cependant, la bonne pratique serait d'utiliser des secrets Docker ou une variable d'environnement non versionnee.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/client.ts lignes 176-202
Severite : BASSE (contexte mono-utilisateur)
async exec(instance: string, command: string): Promise<ExecResult> {
const inst = this.userCtx.instances.find(...);
const res = await fetch(`${this.connectorsUrl}/api/ssh/execute`, {
body: JSON.stringify({ instance_id: inst.id, command }),
});
Les commandes SSH sont passees telles quelles. Les agents LLM construisent ces commandes a partir des inputs utilisateur et de leur raisonnement.
Mais : Le seul chemin pour envoyer des commandes est via les agents LLM, qui sont declenches par des messages WS authentifies (API key). L'utilisateur est aussi le proprietaire des machines SSH. C'est de l'execution de commande "by design".
async writeFile(instance: string, path: string, content: string): Promise<void> {
const escaped = content.replace(/'/g, "'\\''");
const result = await this.exec(instance, `cat > "${path}" << 'ULIAS_EOF'\n${escaped}\nULIAS_EOF`);
Le writeFile echappe les single quotes dans le heredoc. Si le contenu contient ULIAS_EOF en debut de ligne, le heredoc se termine prematurement et le reste du contenu est interprete comme une commande shell.
Classification : RISQUE THEORIQUE — en mono-utilisateur, l'utilisateur execute ses propres commandes. Le risque d'injection via ULIAS_EOF dans le contenu est reel mais improbable en pratique (quel fichier contiendrait ce pattern?).
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 213-428
Severite : BASSE
Le handler handleWsMessage ne verifie pas la taille du message entrant. Un client malveillant pourrait envoyer un message de plusieurs MB qui serait parse, traite, et passe au LLM.
Impact : En mono-utilisateur authentifie, c'est un self-DOS. En pratique, le LLM a un num_ctx de 16384 tokens et le context-builder tronque les inputs.
Classification : RISQUE THEORIQUE
Tous les endpoints REST retournent { error: 'message' } sans setter set.status. Elysia retourne automatiquement un 200 meme en cas d'erreur applicative.
Cependant : C'est un pattern courant dans les APIs internes. Le frontend et le CLI ne verifient que le champ error dans le JSON, pas le status HTTP. Ce n'est pas un bug — c'est un choix architectural. Les seuls endpoints qui pourraient beneficier d'un 401 sont les endpoints non-authentifies, mais le front gere deja le cas via le champ error.
Classification : FAUX POSITIF pour un bug. C'est une amelioration potentielle, pas un defaut fonctionnel.
Comme verifie au point 2.2, l'env var est correctement injectee en prod via conf.prod.gouroubleu.yml : CONNECTORS_API_URL: "http://192.168.1.12:5403". Le fallback 5400 n'est jamais utilise en production.
Classification : FAUX POSITIF pour la prod. Bug en dev local seulement (voir 2.2).
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agents/registry.ts lignes 640-655
const promptOverrides = new Map<string, string>();
export function setPromptOverride(agentName: string, prompt: string): void {
promptOverrides.set(agentName, prompt);
}
Les overrides de prompts sont stockes dans une variable globale (promptOverrides) au niveau du module. Si deux sessions differentes chargent des overrides differents, la derniere session ecrase les overrides de la premiere.
En pratique mono-utilisateur, ce n'est pas un probleme. Mais c'est une dette technique pour le multi-tenant.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 197-210
setInterval(() => {
const now = Date.now();
for (const [key, session] of sessions) {
if (now - session.lastActivity > SESSION_IDLE_MS) {
sessions.delete(key);
suggestionsCache.delete(session.userCtx.userId);
}
}
}, CLEANUP_INTERVAL_MS);
Quand une session idle est supprimee, le WS associe (session.ws) n'est pas ferme. Le client reste connecte mais sa session est detruite. Le prochain message WS echouera avec "Not authenticated".
Ce n'est pas un crash (le front gere l'erreur), mais l'UX serait meilleure si on fermait le WS avec un code et un message explicite.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/director/index.ts lignes 666-684
private async translateOutput(output: string): Promise<string> {
if (output.length < 20) return output;
try {
const response = await this.toolsClient.llmChat(
[
{ role: 'system', content: 'Tu es un traducteur...' },
{ role: 'user', content: output },
],
{ model: 'qwen3:8b', temperature: 0, num_ctx: 4096 }
);
Chaque reponse agent passe par un appel LLM supplementaire pour traduction, meme si elle est deja en francais. Le prompt dit "Si le texte est deja en francais, renvoie-le tel quel", mais cela consomme quand meme un job GPU, ajoute de la latence (~5-10s), et risque de deformer la reponse (le LLM peut "traduire" du code ou des commandes).
C'est un choix delibere, pas un bug, mais l'impact en performance est significatif : chaque requete utilisateur genere au minimum 3 appels LLM (routing + agent + traduction) au lieu de 2.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/director/index.ts lignes 690-704
private async fetchRelevantLessons(message: string): Promise<string[]> {
try {
const embedding = await this.toolsClient.llmEmbed(message);
const lessons = await this.db.getRelevantLessons(this.userCtx.userId, embedding, 5);
A chaque tache, un embedding est genere (1 appel LLM) et 50 lessons sont recuperees de la DB puis classees cote application. Pour une requete multi-step avec 3 sous-taches, cela fait 3 embeddings + 3 queries DB de 50 rows chacune.
Ce n'est pas un bug, mais c'est une source de latence.
Fichiers : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 1195-1209
.post('/internal/job-callback/:callbackId', async ({ params, body }) => {
const { callbackId } = params;
const resolved = resolveJobCallback(callbackId, body as any);
return { ok: true };
})
.get('/internal/pending-jobs', () => {
return { pending: getPendingJobIds() };
})
Les endpoints /internal/* ne verifient aucune authentification. N'importe qui pouvant atteindre le port 5515 peut :
Evaluation :
/internal/* (a verifier), ils ne sont pas accessibles de l'exterieur.Classification : RISQUE THEORIQUE — l'UUID rend l'exploitation improbable, mais l'absence d'auth est un mauvais pattern.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/auth.ts lignes 160-198
async function storeApiKey(...): Promise<void> {
const prefs = { ...(profile.preferences || {}), connector_api_key: apiKey };
await fetch(`${connectorsUrl}/api/fetch`, {
body: JSON.stringify({
connector: 'supabase',
method: 'PATCH',
path: `/rest/v1/ceo_profile?id=eq.${profile.id}`,
body: { preferences: prefs },
}),
});
L'API key connectors-api est stockee en clair dans la table agents.ceo_profile, colonne preferences, champ connector_api_key. Toute personne ayant acces a Supabase (meme en lecture seule) peut recuperer l'API key.
Classification : RISQUE THEORIQUE — en self-hosted mono-utilisateur, le risque est limite. En multi-tenant, ce serait critique.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 515-535
.delete('/api/objectives/:id', async ({ headers, params }) => {
const apiKey = headers['x-api-key'];
const session = await resolveSession(apiKey);
if (!session) return { error: 'Not authenticated' };
try {
const db = new AgentsDB(session.toolsClient);
const obj = await db.getObjective(params.id);
if (!obj) return { error: 'Objective not found' };
// ← Pas de verification obj.user_id === session.userCtx.userId
await db.deleteObjective(params.id);
Idem pour POST /api/objectives/:id/cancel, GET /api/objectives/:id, et DELETE /api/conversations/:id. L'authentification est verifiee mais l'ownership de l'objet ne l'est pas. Un utilisateur authentifie pourrait supprimer les objectifs d'un autre utilisateur.
Classification : RISQUE THEORIQUE — en mono-utilisateur, pas d'impact. En multi-tenant, ce serait une vulnerabilite IDOR.
num_ctx manquant dans certains appels llmChatObservation : Le translateOutput (director, ligne 675) passe num_ctx: 4096 dans un objet LLMOptions. Mais LLMOptions interface (client.ts) n'a pas de champ num_ctx. Ce champ est ajoute par la methode llmChat elle-meme (ligne 324: inputParams.num_ctx = 16384).
Le num_ctx: 4096 passe par translateOutput est un champ inconnu dans LLMOptions et sera ignore silencieusement par le spread. Le LLM recevra toujours num_ctx: 16384.
Ce n'est pas un crash mais le code pense passer 4096 alors qu'il passe 16384. Ce n'est pas grave (16384 suffit) mais c'est trompeur.
getAgent(name)! avec null assertionFichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agents/interpreter.ts lignes 321, 327, 333
const agentDef = getAgent('monitor')!;
Utilise la non-null assertion ! sans verification. Si un agent est renomme ou supprime du registre, le runtime crashera. Le code du director (ligne 591-601) gere correctement ce cas avec un fallback vers explorer.
any frequentsDe nombreux endroits utilisent body as any, query as any, etc. C'est un compromis Elysia (pas de validation de schema declaree). Non critique mais augmente le risque de bugs sur les inputs.
| # | Finding | Classification | Severite | Action |
|---|---|---|---|---|
| 1.1 | Briefing processAnswer logique inversee | BUG CERTAIN | HAUTE | Corriger la condition |
| 1.2 | CLI port 5510 au lieu de 5515 | BUG CERTAIN | MOYENNE | Changer le port default |
| 1.3 | ws.send apres deconnexion | BUG CERTAIN | MOYENNE | Ajouter guard readyState |
| 2.1 | CALLBACK_BASE_URL hardcode sans env | BUG PROBABLE | HAUTE | Ajouter dans conf.prod |
| 2.2 | CONNECTORS_API_URL fallback 5400 | BUG PROBABLE | BASSE | Corriger fallback ou supprimer |
| 2.3 | Cache hash faible | BUG PROBABLE | BASSE | Ameliorer le hash |
| 2.4 | message_count non-atomique | BUG PROBABLE | BASSE | Utiliser increment SQL |
| 2.5 | NotificationService inutilisee | BUG PROBABLE | BASSE | Integrer ou supprimer |
| 3.1 | /api/agents sans auth | RISQUE THEORIQUE | BASSE | Ajouter auth check |
| 3.2 | GITLAB_TOKEN en clair | RISQUE THEORIQUE | MOYENNE | Utiliser Docker secrets |
| 3.3 | Injection heredoc writeFile | RISQUE THEORIQUE | BASSE | Utiliser un delimiteur unique |
| 3.4 | Pas de limite taille WS | RISQUE THEORIQUE | BASSE | Ajouter maxPayloadLength |
| 4.1 | HTTP status codes | FAUX POSITIF | — | — |
| 4.2 | Port 5400 en prod | FAUX POSITIF | — | — |
ws.readyState === 1 avant chaque sendAjout 15-02-2026 : L'audit initial couvrait principalement
index.ts,briefing/index.ts,director/index.ts,agents/registry.ts,tools/client.ts,tools/response-cache.ts,notifications/index.ts,auth.ts, et le CLI. Les fichiers suivants n'avaient PAS ete audites ou l'avaient ete superficiellement :
context-builder/index.tstools/git-workflow.tstools/circuit-breaker.tstools/definitions.tspipelines/index.tsagent-runner/index.ts(mentionne mais pas audite en profondeur)db/index.ts(mentionne mais pas audite en profondeur).env.deployDockerfileconf.prod.gouroubleu.yml(partiellement couvert)
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/context-builder/index.ts lignes 23-25
Severite : BASSE
const TOKEN_BUDGET = 6000;
const CHARS_PER_TOKEN = 3.5;
const MAX_CHARS = TOKEN_BUDGET * CHARS_PER_TOKEN; // ~21000 chars
Le ratio 3.5 chars/token est approximatif. Pour du texte technique (noms de variables, IPs, chemins), le ratio reel est plutot ~4-5 chars/token. Pour du francais, c'est plutot 2.5-3 chars/token (les diacritiques comptent plus).
Impact : Le budget est fixe a ~21000 chars, mais avec num_ctx: 16384 dans llmChat (client.ts ligne 324), le vrai budget en tokens est de ~16384 - messages_overhead. L'approximation sous-estime la taille reelle du prompt en tokens pour le texte technique, ce qui pourrait causer des troncatures non prevues par le LLM (messages tronques au milieu).
En pratique, le num_ctx hardcode a 16384 est le vrai garde-fou. Le context-builder sert de pre-filtre cote application. Le risque est faible car les layers fixes (rules + state) sont rarement >6000 chars.
Classification : RISQUE THEORIQUE — le budget est approximatif mais fonctionne comme pre-filtre grossier.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/context-builder/index.ts lignes 81, 116-117
Severite : BASSE
private assembleWithTrimming(layers: ContextLayer[]): string {
layers.sort((a, b) => a.priority - b.priority); // mute l'array en place
// ...
return [...fixedLayers, ...trimmedLayers]
.sort((a, b) => a.priority - b.priority) // re-sort
.map(l => l.content)
.join('\n\n');
}
Le premier .sort() mute le tableau layers recu en parametre. Ce n'est pas un useStore Qwik donc pas de boucle infinie, mais c'est un anti-pattern : le caller pourrait ne pas s'attendre a ce que son tableau soit mute. La methode build() cree le tableau localement, donc pas de side-effect en pratique.
Classification : QUALITE DE CODE — pas de bug, mais le spread avant sort serait plus propre.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/git-workflow.ts lignes 31-37
Severite : BASSE
let status: ChangedFile['status'] = 'modified';
if (statusCode === '??' || statusCode === 'A') status = 'added';
else if (statusCode === 'D') status = 'deleted';
else if (statusCode === 'R') status = 'renamed';
else if (statusCode === '??') status = 'untracked';
Le premier if teste deja statusCode === '??' et assigne 'added'. Le dernier else if teste a nouveau '??' mais ne sera JAMAIS atteint (court-circuit par le premier if). Les fichiers non-trackes (??) seront donc classes comme 'added' au lieu de 'untracked'.
Le type ChangedFile['status'] inclut 'untracked' comme valeur possible, mais cette valeur n'est jamais assignee.
Impact : Fonctionnel mais semantiquement faux. Si un agent utilise le status 'untracked' pour decider quoi stager (ex: ignorer les fichiers non-trackes), il ne les detectera jamais car ils sont classes comme 'added'.
Classification : BUG CERTAIN — logique mort, 'untracked' n'est jamais assigne. Le fix serait d'inverser l'ordre (tester '??' pour untracked en premier, puis 'A' pour added).
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/git-workflow.ts
Severite : BASSE
Ce module exporte 4 fonctions (getChangedFiles, ensureCleanWorkingTree, createBranchAndCommit, getCurrentBranch) mais aucune n'est importee dans le reste du codebase serveur.
Verification :
grep -r "git-workflow" packages/server/src/ → aucun resultat
grep -r "getChangedFiles\|ensureCleanWorkingTree\|createBranchAndCommit\|getCurrentBranch" packages/server/src/ → aucun resultat hors git-workflow.ts
Les operations git des agents passent directement par ToolsClient.gitCommit(), ToolsClient.exec(), etc. Le module git-workflow est du code mort prepare pour un usage futur.
Classification : CODE MORT — pas de regression, mais augmente la surface de maintenance.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/circuit-breaker.ts
Fichier d'import : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agent-runner/index.ts ligne 13
const toolCircuitBreaker = new CircuitBreaker(3, 60000);
Le circuit breaker est instancie une fois au niveau du module agent-runner. Il est partage entre TOUS les agents et TOUTES les sessions. Si un agent "coder" fait echouer l'outil exec 3 fois de suite (ex: SSH timeout), le circuit s'ouvre et TOUS les agents de TOUTES les sessions se voient refuser exec pendant 60 secondes.
Impact : En mono-utilisateur, c'est plutot un feature (empeche les cascades). En multi-agent concurrent, un agent qui boucle sur un tool defaillant peut bloquer les autres agents.
Classification : RISQUE THEORIQUE — correct pour mono-utilisateur, pourrait poser probleme en multi-tenant.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agents/registry.ts ligne 635
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/definitions.ts lignes 658-692
// registry.ts:
'storage-dev': {
// ...
toolSet: 'infra', // ← pointe vers le toolset 'infra', PAS vers 'storage-dev'
}
L'agent storage-dev a toolSet: 'infra', ce qui lui donne les outils [execTool, dockerPsTool, dockerRestartTool, notifyTool]. Il n'a PAS les outils storageXxx definis dans TOOL_SETS['storage-dev'].
Cependant, le prompt de storage-dev (registry.ts lignes 598-637) dit explicitement : "ACCESS METHOD: Use exec() on prod-portainer-SSH to run curl commands directly. Do NOT use the storageXxx tools — they have auth issues."
Verification : Le TOOL_SETS['storage-dev'] existe et contient [storageListBucketsTool, storageCreateBucketTool, ...]. Mais l'agent storage-dev utilise toolSet: 'infra' intentionnellement car les outils storage ont des problemes d'auth (comme documente dans le prompt).
Classification : CHOIX DELIBERE — pas un bug, mais confusant. Le TOOL_SETS['storage-dev'] est defini mais jamais utilise par l'agent storage-dev. Les tools storage y sont car ils sont potentiellement utilises par d'autres pipelines (mais en verifiant les TOOL_SETS, seul 'storage-dev' key les contient, et aucun agent ne l'utilise).
Consequence reelle : Les 7 tools de TOOL_SETS['storage-dev'] (storageListBuckets, storageCreateBucket, storageListFiles, storageUpload, storageDelete, storageGetPublicUrl) sont du code mort — definis mais jamais passes a un agent.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/client.ts ligne 637
async storageGetPublicUrl(bucket: string, path: string): Promise<string> {
const sbInstance = this.userCtx.instanceMap.supabase;
if (!sbInstance) throw new Error('No Supabase instance configured');
const baseUrl = sbInstance.config?.url || 'http://192.168.1.12:8200';
return `${baseUrl}/storage/v1/object/public/${bucket}/${path}`;
}
Le fallback http://192.168.1.12:8200 est une IP hardcodee. Selon les regles CLAUDE.md (regle 17), les IPs hardcodees dans le code sont a eviter car Pi-hole split-DNS gere la resolution. L'URL correcte serait https://supabase.33800.nowhere84.com.
Impact : Si sbInstance.config?.url est undefined (ce qui depend de la config du connecteur Supabase dans connectors-api), l'URL generee sera en HTTP sur une IP interne, ce qui :
Note : Ce code est dans un chemin mort car storageGetPublicUrl n'est appelee que via le tool storageGetPublicUrl dans agent-runner/index.ts ligne 594, et l'agent storage-dev n'a pas ce tool (voir 10.6). Seul index.ts ligne 895 appelle directement session.toolsClient.storageGetPublicUrl() dans le endpoint /api/upload.
Classification : BUG PROBABLE — IP hardcodee dans le code applicatif, violation de la regle Pi-hole. Impact reel limite car le chemin est rarement utilise.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/tools/client.ts lignes 220-233
async editFile(instance: string, path: string, oldString: string, newString: string): Promise<void> {
const content = await this.readFile(instance, path);
if (!content.includes(oldString)) {
throw new Error(`editFile: old_string not found in ${path}`);
}
const newContent = content.replace(oldString, newString); // ← String.replace sans /g
await this.writeFile(instance, path, newContent);
}
String.replace(string, string) ne remplace que la premiere occurrence. Si le LLM-agent veut remplacer un pattern qui apparait plusieurs fois (ex: renommer une variable), seule la premiere sera modifiee.
Ce comportement est identique a celui de Claude Code (qui remplace la premiere occurrence pour eviter les effets de bord), donc c'est probablement intentionnel. Mais la description du tool (editFile dans definitions.ts ligne 63) dit juste "replace old_string with new_string" sans preciser "first occurrence only".
Classification : QUALITE DE CODE — probablement intentionnel mais le comportement devrait etre documente dans la tool description pour que le LLM sache qu'il doit faire plusieurs appels si le pattern apparait plusieurs fois.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agent-runner/index.ts lignes 193-195
const parallelResults = await Promise.all(
canParallel.map(async (toolCall) => {
toolCallsCount++; // ← increment dans une Promise.all
toolCallsCount++ est execute dans des callbacks concurrents de Promise.all. En JavaScript single-thread, l'increment est atomique car il n'y a pas de preemption entre les ++. Cependant, si executeTool() est synchrone pour certains outils (retour immediat), les increments sont effectivement sequentiels. Si executeTool() contient des await, les callbacks reprennent dans un ordre non-deterministe, mais ++ reste atomique en single-thread.
Classification : FAUX POSITIF — JavaScript single-thread garantit l'atomicite de ++. Pas de race condition possible.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agent-runner/index.ts lignes 460-476
// LLM summarization for large results
try {
const summary = await this.toolsClient.llmChat(
[
{ role: 'system', content: 'Tu es un assistant qui resume des donnees...' },
{ role: 'user', content: `Resume ces donnees (${slimStr.length} chars):\n${slimStr.slice(0, 12000)}` },
],
{ model: 'qwen3:8b', temperature: 0, num_ctx: 16384, timeout: 30000 }
);
Quand un outil retourne un resultat >6000 chars et que le field-filtering ne suffit pas, compactToolResult fait un appel LLM supplementaire pour resumer. Cela ajoute :
C'est un choix architectural documente mais non mentionne dans l'audit initial. Combine avec la traduction (section 5.3) et le routing (section 5.4), une seule requete utilisateur peut declencher : 1 routing + 1 embedding + N agent iterations + M summarizations + 1 traduction = potentiellement >10 appels LLM.
Note : num_ctx: 16384 est passe directement dans les options ici, ce qui est la bonne valeur. Contrairement a translateOutput (finding 7.1) ou num_ctx: 4096 est ignore, ici le num_ctx n'est pas dans LLMOptions mais est passe comme extra param — il sera spread dans inputParams via ...options dans llmChat. Verification : en fait non, llmChat ne spread PAS les options non reconnues. Il lit options.model, options.temperature, options.tools, options.gpu, options.priority, options.maxTokens, options.timeout, options.provider — c'est tout. Le num_ctx passe ici est egalement ignore, tout comme dans translateOutput.
Correction du finding : compactToolResult a le meme probleme que translateOutput (7.1) : le num_ctx passe dans les options est silencieusement ignore. Le num_ctx est toujours 16384 (hardcode dans llmChat ligne 324).
Classification : PROBLEME ARCHITECTURAL (latence) + BUG PROBABLE (num_ctx ignore, meme probleme que 7.1)
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agent-runner/index.ts lignes 101-102, 210-222
let consecutiveErrors = 0;
let lastErrorMsg = '';
// ...
// Dans Promise.all:
if (resultStr.startsWith('Tool error:')) {
const errKey = resultStr.slice(0, 200);
if (errKey === lastErrorMsg) {
consecutiveErrors++;
} else {
consecutiveErrors = 1;
lastErrorMsg = errKey;
}
} else {
consecutiveErrors = 0;
lastErrorMsg = '';
}
Les variables consecutiveErrors et lastErrorMsg sont modifiees dans les callbacks de Promise.all. Si 3 tools executent en parallele et les 3 echouent avec la meme erreur, les 3 callbacks incrementent consecutiveErrors de maniere potentiellement non-deterministe. En JavaScript single-thread, chaque callback s'execute atomiquement entre les await, donc l'increment final sera correct (3). Cependant :
consecutiveErrors est remis a 0 (par le succes) puis incremente, ou incremente puis remis a 0. Le resultat depend de l'ordre d'execution.Classification : BUG PROBABLE — la semantique de "consecutive" ne s'applique pas proprement aux executions paralleles. L'impact est faible (l'auto-stop a 3 erreurs est un garde-fou secondaire).
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/pipelines/index.ts
Severite : AUCUNE
Le module est correctement implemente :
runSequential : execute les steps dans l'ordre, passe le contexte entre les steps, respecte stopOnFailure et gateCondition.runParallel : execute via Promise.all, merge les resultats.buildAgentConfig : recupere la definition d'agent et mappe les tools correctement.Seul point d'attention : les pipelines predefinis (create_frontend, create_fullstack, etc.) supposent que les agents gitlab-dev, qwik-dev, elysia-dev, pg-dev existent dans le registre. C'est le cas actuellement mais si un agent est renomme, le pipeline echouera proprement (message d'erreur "Agent not found").
Classification : PAS DE BUG
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/db/index.ts lignes 516-523
async searchConversations(userId: string, query: string, limit = 20): Promise<DBConversation[]> {
const titleResults = await this.client.dbQuery('agents.conversations', {
filter: { user_id: userId, title: `ilike.*${query}*` },
// ...
});
const msgResults = await this.client.dbQuery('agents.chat_messages', {
select: 'conversation_id',
filter: { user_id: userId, content: `ilike.*${query}*` },
// ...
});
Le query utilisateur est injecte directement dans le filtre PostgREST ilike.*${query}*. PostgREST echappe les valeurs dans les filtres (c'est un parametre URL, pas du SQL brut), donc il n'y a pas d'injection SQL directe. Cependant :
query = "eq.test" qui commencerait par eq. et serait interprete comme un operateur PostgREST par buildFilterQuery).Verification dans buildFilterQuery (client.ts lignes 471-484) : la methode verifie si la valeur commence par eq., gt., ilike., etc., et si oui, la passe telle quelle. Ici, la valeur est deja prefixee par ilike.*, donc elle sera detectee comme un operateur PostgREST et passee telle quelle. Le query de l'utilisateur est concatene a l'interieur du pattern ilike.*${query}*. Si query contient des * ou des %, cela modifie le pattern de recherche mais ne cause pas d'injection SQL.
Classification : RISQUE THEORIQUE — pas d'injection SQL, mais le pattern de recherche peut etre altere par des caracteres speciaux dans l'input utilisateur.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/db/index.ts ligne 590
async forkConversation(originalConvId: string, userId: string, upToMessageId: string, newTitle: string): Promise<...> {
const allMessages = await this.getChatMessages(originalConvId, 10000);
La fonction charge jusqu'a 10000 messages en memoire pour trouver le upToMessageId. Chaque message est ensuite copie un par un avec des saveChatMessage sequentiels (ligne 600-610). Pour une conversation de 1000 messages, c'est 1 query + 1000 inserts sequentiels via l'API REST → potentiellement >30 secondes de latence.
Classification : PROBLEME ARCHITECTURAL — fonctionne mais tres lent pour les longues conversations. Une solution serait de copier les messages cote serveur via une RPC PostgreSQL.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/db/index.ts lignes 262-274
async saveMessages(messages: DBMessage[]): Promise<void> {
for (const msg of messages) {
await this.client.dbInsert('agents.messages', { ... });
}
}
Chaque message est insere un par un via des appels REST sequentiels. PostgREST supporte les inserts batch (envoyer un tableau d'objets en POST), ce qui serait bien plus performant. Ce n'est pas un bug mais une opportunite d'optimisation.
Classification : QUALITE DE CODE — pas de bug, optimisation possible.
Fichier : /stock_8to/33800-stack/projects/ulias-org/.env.deploy
PORT=5515
NODE_ENV=production
CONNECTORS_API_URL=http://192.168.1.12:5403
GITLAB_URL=https://gitlab.33800.nowhere84.com
GITLAB_TOKEN=glpat-yaowLwWBJhXfzJEC8UBC
Ce fichier est auto-genere par smart-deploy.sh (voir le commentaire en tete). Il contient le meme GITLAB_TOKEN que conf.prod.gouroubleu.yml. Le fichier est versionne dans le repo GitLab (verifie : pas de .gitignore qui l'exclut).
Probleme supplementaire : CONNECTORS_API_URL utilise l'IP 192.168.1.12:5403 au lieu du domaine HTTPS. C'est un fichier de config Docker deploy (pas du code applicatif), donc la regle Pi-hole ne s'applique pas strictement — les containers Docker communiquent via les IPs internes car ils n'utilisent pas Pi-hole comme DNS. Cependant, c'est un risque de maintenance si l'IP change.
Classification : RISQUE THEORIQUE (memes remarques que finding 3.2 — token en clair dans un repo prive).
Fichier : /stock_8to/33800-stack/projects/ulias-org/Dockerfile
FROM oven/bun:1 AS base
WORKDIR /app
COPY package.json bun.lockb* ./
COPY packages/server/package.json ./packages/server/
RUN bun install --frozen-lockfile 2>/dev/null || bun install
COPY tsconfig.json ./
COPY packages/server/ ./packages/server/
EXPOSE 5515
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:5515/health || exit 1
CMD ["bun", "run", "packages/server/src/index.ts"]
Le Dockerfile est fonctionnel et bien structure (cache des deps avant le code, healthcheck). Points d'attention :
2>/dev/null || bun install : Si le lockfile est corrompu, --frozen-lockfile echoue silencieusement et bun install recree le lockfile, potentiellement avec des versions differentes de la prod precedente. C'est un risque de reproductibilite.curl : L'image oven/bun:1 est basee sur Debian et inclut curl. Le check est correct.Classification : QUALITE DE CODE — fonctionnel, le fallback bun install est un risque mineur de reproductibilite.
Fichier : /stock_8to/33800-stack/projects/ulias-org/conf.prod.gouroubleu.yml lignes 14-18
nginx:
enabled: true
domain: "ulias-org.33800.nowhere84.com"
ssl: true
websocket: true
La section nginx ne contient PAS private: true. Cela signifie que le service est accessible publiquement sur Internet via le domaine ulias-org.33800.nowhere84.com.
Combinee avec le finding 3.1 (GET /api/agents sans auth expose les prompts) et le finding 6.1 (endpoints /internal/* sans auth), cela implique que :
/internal/pending-jobs et /internal/job-callback/:id sont accessibles depuis Internet (meme si le callbackId est un UUID)La combinaison des findings 3.1 + 10.18 eleve la severite : ce n'est plus "theorique" si le service est public. Un scan DNS ou un brute-force de sous-domaines peut reveler ulias-org.33800.nowhere84.com.
Classification : RISQUE DE SECURITE — le service devrait etre private: true ou les endpoints sensibles devraient etre proteges par auth.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/agent-runner/index.ts ligne 14
const responseCache = new ResponseCache(50, 5 * 60 * 1000);
Le cache de reponses est global (meme variable pour toutes les sessions, tous les agents). Si deux sessions differentes envoient le meme message au meme agent, la deuxieme recevra la reponse cachee de la premiere. En mono-utilisateur, c'est un gain de performance. En multi-tenant, c'est une fuite d'information.
Le hash de cache (response-cache.ts) utilise model + systemPrompt[:200] + lastUserMessage. Deux sessions avec le meme agent et le meme message obtiendront le meme hash.
Classification : RISQUE THEORIQUE — en mono-utilisateur, c'est un feature. En multi-tenant, c'est critique.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/auth.ts ligne 28
const userId = data.user_id || data.userId || apiKey.slice(0, 8);
Si connectors-api ne renvoie ni user_id ni userId dans la reponse /api/user/instances, le userId est construit a partir des 8 premiers caracteres de l'API key. C'est un fallback fragile :
Impact : En pratique, connectors-api renvoie toujours un user_id. Le fallback n'est utilise que si l'API est en mode degrade.
Classification : BUG PROBABLE — le fallback est fragile et peut causer des collisions d'identite.
Fichier : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts lignes 891-893
const decoded = Buffer.from(content, 'base64').toString('utf-8');
await session.toolsClient.storageUpload(bucket, path, decoded, contentType || 'application/octet-stream');
Le contenu base64 est decode en string UTF-8 avant d'etre envoye au storage. Si le fichier est binaire (image, PDF, etc.), la conversion en UTF-8 corrompt les donnees. Le contentType par defaut est application/octet-stream (binaire), mais le contenu est traite comme texte.
Classification : BUG CERTAIN — les uploads binaires sont corrompus. Seuls les fichiers texte (HTML, CSS, JS, JSON) fonctionnent correctement.
| # | Finding | Classification | Severite | Fichier |
|---|---|---|---|---|
| 1.1 | Briefing processAnswer logique inversee | BUG CERTAIN | HAUTE | briefing/index.ts |
| 1.2 | CLI port 5510 au lieu de 5515 | BUG CERTAIN | MOYENNE | cli/bin/ulias.ts |
| 1.3 | ws.send apres deconnexion | BUG CERTAIN | MOYENNE | index.ts |
| 10.3 | git-workflow '??' teste deux fois, untracked jamais assigne | BUG CERTAIN | BASSE | tools/git-workflow.ts |
| 10.21 | Upload base64 decode en UTF-8 corrompt les binaires | BUG CERTAIN | MOYENNE | index.ts |
| 2.1 | CALLBACK_BASE_URL hardcode sans env | BUG PROBABLE | HAUTE | tools/client.ts |
| 2.2 | CONNECTORS_API_URL fallback 5400 | BUG PROBABLE | BASSE | index.ts |
| 2.3 | Cache hash faible | BUG PROBABLE | BASSE | tools/response-cache.ts |
| 2.4 | message_count non-atomique | BUG PROBABLE | BASSE | index.ts |
| 2.5 | NotificationService inutilisee | BUG PROBABLE | BASSE | notifications/index.ts |
| 10.7 | storageGetPublicUrl IP hardcodee | BUG PROBABLE | BASSE | tools/client.ts |
| 10.10 | compactToolResult num_ctx ignore (meme pb que 7.1) | BUG PROBABLE | BASSE | agent-runner/index.ts |
| 10.11 | consecutiveErrors semantique incorrecte en parallele | BUG PROBABLE | BASSE | agent-runner/index.ts |
| 10.20 | userId fallback fragile (apiKey[:8]) | BUG PROBABLE | BASSE | auth.ts |
| 3.1 | /api/agents sans auth | RISQUE THEORIQUE | BASSE | index.ts |
| 3.2 | GITLAB_TOKEN en clair | RISQUE THEORIQUE | MOYENNE | conf.prod.gouroubleu.yml |
| 3.3 | Injection heredoc writeFile | RISQUE THEORIQUE | BASSE | tools/client.ts |
| 3.4 | Pas de limite taille WS | RISQUE THEORIQUE | BASSE | index.ts |
| 10.1 | Estimation token approximative | RISQUE THEORIQUE | BASSE | context-builder/index.ts |
| 10.5 | Circuit breaker global partage entre agents | RISQUE THEORIQUE | BASSE | agent-runner/index.ts |
| 10.13 | searchConversations pattern alterable | RISQUE THEORIQUE | BASSE | db/index.ts |
| 10.16 | .env.deploy GITLAB_TOKEN en clair | RISQUE THEORIQUE | BASSE | .env.deploy |
| 10.18 | nginx non private + endpoints sans auth exposés | RISQUE SECURITE | HAUTE | conf.prod.gouroubleu.yml |
| 10.19 | Response cache global partage entre sessions | RISQUE THEORIQUE | BASSE | agent-runner/index.ts |
| 4.1 | HTTP status codes | FAUX POSITIF | — | — |
| 4.2 | Port 5400 en prod | FAUX POSITIF | — | — |
| 10.9 | toolCallsCount non-thread-safe dans Promise.all | FAUX POSITIF | — | agent-runner/index.ts |
| 10.4 | git-workflow code mort | CODE MORT | BASSE | tools/git-workflow.ts |
| 10.6 | TOOL_SETS['storage-dev'] jamais utilise | CODE MORT | BASSE | tools/definitions.ts |
| 10.12 | Pipelines bien structures | PAS DE BUG | — | pipelines/index.ts |
private: true dans nginx (10.18) — Le service est public, les prompts et endpoints internes sont exposesws.readyState === 1 avant chaque sendnum_ctx dans LLMOptions (7.1 + 10.10) — Ou documenter que c'est toujours 16384Audit initial realise par lecture integrale de 16 fichiers TypeScript, 1 YAML, 1 Dockerfile, 2 package.json. Complement realise le 15-02-2026 par lecture integrale des 6 fichiers non couverts + re-verification croisee de tous les fichiers. Total : 16 fichiers .ts, 1 YAML, 1 .env.deploy, 1 Dockerfile. Chaque finding trace jusqu'au code source exact avec verification des chemins d'execution.