@fkom13/mcp-sftp-orchestrator 11.3.0 → 11.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -22,7 +22,15 @@ import notes from './notes.js';
22
22
  import guide from './guide.js';
23
23
  import policies from './policies.js';
24
24
  import tunnels from './tunnels.js';
25
+ import fleet from './fleet.js';
26
+ import groups from './groups.js';
27
+ import projects from './projects.js';
28
+ import workSession from './workSession.js';
29
+ import inventory from './inventory.js';
30
+ import sshTrust from './sshTrust.js';
25
31
  import { sourceSchema } from './sourceAdapter.js';
32
+ import infraTopology from './infraTopology.js';
33
+ import { enhanceToolConfig } from './toolMetadata.js';
26
34
 
27
35
  if (DEBUG) {
28
36
  console.error("✅ Tous les modules importés");
@@ -33,28 +41,89 @@ await queue.init();
33
41
 
34
42
  if (DEBUG) console.error("✅ Queue initialisée");
35
43
 
44
+ if (config.readOnly) {
45
+ console.error("[INFO] MCP_READONLY actif — écritures / exec mutantes refusées.");
46
+ }
47
+ if (config.compact) {
48
+ console.error("[INFO] MCP_COMPACT actif — réponses tronquées.");
49
+ }
50
+
36
51
  // Vérification des secrets en clair
37
52
  try {
38
53
  const serverList = await servers.listServers();
39
- for (const [alias, config] of Object.entries(serverList)) {
40
- if (config.password) {
54
+ for (const [alias, sc] of Object.entries(serverList)) {
55
+ if (sc.password) {
41
56
  console.error(`[SECURITY WARN] Mot de passe en clair détecté pour le serveur '${alias}'. Utilisez une clé SSH ou Vaultwarden.`);
42
57
  }
43
58
  }
44
59
  const apiList = await apis.listApis();
45
- for (const [alias, config] of Object.entries(apiList)) {
46
- if (config.api_key || config.htpasswd_pass) {
60
+ for (const [alias, ac] of Object.entries(apiList)) {
61
+ if (ac.api_key || ac.htpasswd_pass) {
47
62
  console.error(`[SECURITY WARN] Secret en clair détecté pour l'API '${alias}'. Utilisez Vaultwarden.`);
48
63
  }
49
64
  }
50
65
  } catch (e) { /* silencieux, pas bloquant */ }
51
66
 
67
+ const ORCH_VERSION = config.version || '11.8.0';
68
+
52
69
  const server = new McpServer({
53
70
  name: "orchestrator",
54
- version: "11.3.0",
55
- description: "Serveur pour l'orchestration de tâches distantes avec exécution hybride et configuration flexible."
71
+ version: ORCH_VERSION,
72
+ description: "IACA Resource / Execution Orchestrator : machines, SSH, fichiers, jobs, transferts, terminaux, snapshots et topologie d'infrastructure."
56
73
  });
57
74
 
75
+ // Enrichit les 82 tools avec les hints MCP standard et des descriptions de
76
+ // sélection sans dupliquer cette logique dans chaque registerTool. Les hints
77
+ // n'accordent aucun droit : les guards/policies serveur restent l'autorité.
78
+ const nativeRegisterTool = server.registerTool.bind(server);
79
+ server.registerTool = (name, toolConfig, handler) =>
80
+ nativeRegisterTool(name, enhanceToolConfig(name, toolConfig), handler);
81
+
82
+ /** Refuse si MCP_READONLY global. */
83
+ function guardWritable(actionLabel) {
84
+ if (config.readOnly) {
85
+ utils.assertWritable(actionLabel);
86
+ }
87
+ }
88
+
89
+ /** Refuse si alias marqué readonly:true dans servers.json (ou global). */
90
+ async function guardAliasWritable(alias, actionLabel) {
91
+ guardWritable(actionLabel);
92
+ if (alias && await servers.isAliasReadOnly(alias)) {
93
+ throw new Error(
94
+ `Alias '${alias}' est en lecture seule (servers.json readonly:true). ` +
95
+ `${actionLabel} refusé.`
96
+ );
97
+ }
98
+ }
99
+
100
+ /** Applique la politique d'écriture aux transferts selon leur direction. */
101
+ async function guardTransferWritable(params, actionLabel) {
102
+ guardWritable(actionLabel);
103
+ // upload et server_to_server écrivent sur l'alias cible.
104
+ if (params.direction === 'upload' || params.direction === 'server_to_server') {
105
+ await guardAliasWritable(params.alias, actionLabel);
106
+ }
107
+ // download ne modifie pas le serveur distant, seulement le filesystem local ;
108
+ // le guard global ci-dessus suffit.
109
+ }
110
+
111
+ /** Empêche task_retry de contourner readonly:true d'un alias. */
112
+ async function guardRetryJobWritable(job, actionLabel) {
113
+ guardWritable(actionLabel);
114
+ if (!job) throw new Error('Job introuvable.');
115
+ if (job.type === 'ssh' || job.type === 'ssh_sequence') {
116
+ await guardAliasWritable(job.alias, actionLabel);
117
+ } else if (job.type === 'sftp' && (job.direction === 'upload' || job.direction === 'server_to_server')) {
118
+ await guardAliasWritable(job.alias, actionLabel);
119
+ }
120
+ }
121
+
122
+ function jsonResult(obj, params = {}) {
123
+ const payload = utils.wantsCompact(params) ? utils.compactResult(obj) : obj;
124
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
125
+ }
126
+
58
127
  if (DEBUG) console.error("✅ Serveur MCP créé");
59
128
 
60
129
  // Restaurer les tunnels locaux au démarrage
@@ -75,9 +144,12 @@ server.registerTool(
75
144
  const stats = {
76
145
  queue: queue.getStats(),
77
146
  pool: ssh.getPoolStats(),
78
- servers: await servers.listServers(),
79
- apis: await apis.listApis(),
80
- crashed: queue.getCrashedJobs().length
147
+ // Secrets masqués (api_key, password, htpasswd_pass, …)
148
+ servers: utils.redactSensitiveObject(await servers.listServers()),
149
+ apis: utils.redactSensitiveObject(await apis.listApis()),
150
+ crashed: queue.getCrashedJobs().length,
151
+ version: ORCH_VERSION,
152
+ readOnly: !!config.readOnly
81
153
  };
82
154
 
83
155
  if (params.verbose) {
@@ -101,13 +173,16 @@ server.registerTool(
101
173
  host: z.string().describe("Adresse IP ou nom d'hôte du serveur"),
102
174
  user: z.string().describe("Nom d'utilisateur pour la connexion"),
103
175
  keyPath: z.string().optional().describe("Chemin absolu vers la clé privée SSH."),
104
- password: z.string().optional().describe("Mot de passe pour la connexion.")
176
+ password: z.string().optional().describe("Mot de passe pour la connexion."),
177
+ readonly: z.boolean().optional().describe("Si true, refuse les écritures/exec mutantes sur cet alias."),
178
+ port: z.number().optional().describe("Port SSH (défaut 22).")
105
179
  }).refine(data => data.keyPath || data.password, {
106
180
  message: "Vous devez fournir au moins une méthode d'authentification ('keyPath' ou 'password')."
107
181
  })
108
182
  },
109
183
  async (params) => {
110
184
  try {
185
+ guardWritable('server_add');
111
186
  const { alias, ...serverConfig } = params;
112
187
  const result = await servers.addServer(alias, serverConfig);
113
188
  return { content: [{ type: "text", text: result.message }] };
@@ -130,7 +205,7 @@ server.registerTool(
130
205
  inputSchema: z.object({})
131
206
  },
132
207
  async () => {
133
- const serverList = await servers.listServers();
208
+ const serverList = utils.redactSensitiveObject(await servers.listServers());
134
209
  return { content: [{ type: "text", text: JSON.stringify(serverList, null, 2) }] };
135
210
  }
136
211
  );
@@ -146,6 +221,7 @@ server.registerTool(
146
221
  },
147
222
  async (params) => {
148
223
  try {
224
+ guardWritable('server_remove');
149
225
  const result = await servers.removeServer(params.alias);
150
226
  return { content: [{ type: "text", text: result.message }] };
151
227
  } catch (e) {
@@ -159,6 +235,422 @@ server.registerTool(
159
235
  }
160
236
  );
161
237
 
238
+ // --- GROUPES D'ALIAS ---
239
+
240
+ server.registerTool(
241
+ "server_group_list",
242
+ {
243
+ title: "Lister les groupes d'alias",
244
+ description: "Affiche les groupes de serveurs (oci, contabo, …). Utilisable dans task_exec via alias 'group:nom' ou le nom du groupe.",
245
+ inputSchema: z.object({})
246
+ },
247
+ async () => {
248
+ const g = await groups.listGroups();
249
+ return { content: [{ type: "text", text: JSON.stringify(g, null, 2) }] };
250
+ }
251
+ );
252
+
253
+ server.registerTool(
254
+ "server_group_set",
255
+ {
256
+ title: "Créer/mettre à jour un groupe d'alias",
257
+ description: "Définit un groupe nommé pointant vers une liste d'alias serveurs existants.",
258
+ inputSchema: z.object({
259
+ name: z.string().describe("Nom du groupe (ex: oci, relays)"),
260
+ aliases: z.array(z.string()).min(1).describe("Liste d'alias serveurs")
261
+ })
262
+ },
263
+ async (params) => {
264
+ try {
265
+ guardWritable('server_group_set');
266
+ const result = await groups.setGroup(params.name, params.aliases);
267
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
268
+ } catch (e) {
269
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_group_set", errorMessage: e.message }, null, 2) }], isError: true };
270
+ }
271
+ }
272
+ );
273
+
274
+ server.registerTool(
275
+ "server_group_remove",
276
+ {
277
+ title: "Supprimer un groupe d'alias",
278
+ description: "Supprime un groupe (ne supprime pas les serveurs).",
279
+ inputSchema: z.object({
280
+ name: z.string().describe("Nom du groupe")
281
+ })
282
+ },
283
+ async (params) => {
284
+ try {
285
+ guardWritable('server_group_remove');
286
+ const result = await groups.removeGroup(params.name);
287
+ return { content: [{ type: "text", text: result.message }] };
288
+ } catch (e) {
289
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_group_remove", errorMessage: e.message }, null, 2) }], isError: true };
290
+ }
291
+ }
292
+ );
293
+
294
+ server.registerTool(
295
+ "fleet_status",
296
+ {
297
+ title: "Status SSH de tout le parc",
298
+ description: "Ping SSH parallèle : latence, uptime/load/disk légers, clés manquantes. Filtrable par alias, liste, 'all' ou group:nom.",
299
+ inputSchema: z.object({
300
+ alias: z.union([z.string(), z.array(z.string())]).optional().describe("Alias, group:x, all, ou tableau. Défaut: tout le parc."),
301
+ timeout_s: z.number().optional().default(15).describe("Timeout par serveur en secondes")
302
+ })
303
+ },
304
+ async (params) => {
305
+ try {
306
+ let aliases;
307
+ if (params.alias) {
308
+ aliases = await groups.resolveAliases(params.alias);
309
+ }
310
+ const result = await fleet.fleetStatus({
311
+ aliases,
312
+ timeoutMs: (params.timeout_s || 15) * 1000
313
+ });
314
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
315
+ } catch (e) {
316
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "fleet_status", errorMessage: e.message }, null, 2) }], isError: true };
317
+ }
318
+ }
319
+ );
320
+
321
+ server.registerTool(
322
+ "infra_audit",
323
+ {
324
+ title: "Audit synthèse du parc (1 appel)",
325
+ description: "Vue combinée : version orchestrator, serveurs (secrets masqués), groupes, notes, projets, crashed jobs, pool stats. Idéal en début de session.",
326
+ inputSchema: z.object({
327
+ compact: z.boolean().optional().describe("Réponse tronquée (tokens)")
328
+ })
329
+ },
330
+ async (params) => {
331
+ try {
332
+ const [serverList, groupList, noteList, projectList] = await Promise.all([
333
+ servers.listServers(),
334
+ groups.listGroups(),
335
+ notes.list(),
336
+ projects.list()
337
+ ]);
338
+ const audit = {
339
+ version: ORCH_VERSION,
340
+ readOnly: !!config.readOnly,
341
+ compact: !!config.compact,
342
+ dataDir: config.dataDir,
343
+ servers: utils.redactSensitiveObject(serverList),
344
+ groups: groupList,
345
+ projects: Object.keys(projectList),
346
+ notes: noteList,
347
+ queue: queue.getStats(),
348
+ crashed: queue.getCrashedJobs().map(j => ({ id: j.id, type: j.type, alias: j.alias, crashedAt: j.crashedAt })),
349
+ pool: ssh.getPoolStats(),
350
+ timestamp: new Date().toISOString()
351
+ };
352
+ return jsonResult(audit, params);
353
+ } catch (e) {
354
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "infra_audit", errorMessage: e.message }, null, 2) }], isError: true };
355
+ }
356
+ }
357
+ );
358
+
359
+ // --- PROJETS (A) ---
360
+
361
+ server.registerTool(
362
+ "project_list",
363
+ {
364
+ title: "Lister les projets enregistrés",
365
+ description: "Registre sémantique projet ↔ serveur(s) ↔ paths local/remote.",
366
+ inputSchema: z.object({})
367
+ },
368
+ async () => jsonResult(await projects.list())
369
+ );
370
+
371
+ server.registerTool(
372
+ "project_get",
373
+ {
374
+ title: "Détail d'un projet",
375
+ description: "Retourne la config complète d'un projet (local, servers, ignore, runtime).",
376
+ inputSchema: z.object({ name: z.string() })
377
+ },
378
+ async (params) => {
379
+ try {
380
+ return jsonResult(await projects.get(params.name));
381
+ } catch (e) {
382
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_get", errorMessage: e.message }, null, 2) }], isError: true };
383
+ }
384
+ }
385
+ );
386
+
387
+ server.registerTool(
388
+ "project_set",
389
+ {
390
+ title: "Créer/mettre à jour un projet",
391
+ description: "Enregistre un projet. Ex: { name:'p-image', local:{path:'…'}, servers:{prod:{alias:'fkomprodmini2_prod', path:'/home/ubuntu/p-image', runtime:{pm2:'p-image', port:5002}}}, ignore:['node_modules'] }",
392
+ inputSchema: z.object({
393
+ name: z.string(),
394
+ description: z.string().optional(),
395
+ local: z.object({ path: z.string() }).optional(),
396
+ servers: z.record(z.object({
397
+ alias: z.string(),
398
+ path: z.string(),
399
+ runtime: z.record(z.any()).optional(),
400
+ url: z.string().optional()
401
+ })).optional(),
402
+ ignore: z.array(z.string()).optional(),
403
+ tags: z.array(z.string()).optional(),
404
+ network: z.record(z.any()).optional()
405
+ })
406
+ },
407
+ async (params) => {
408
+ try {
409
+ guardWritable('project_set');
410
+ const { name, ...fields } = params;
411
+ return jsonResult(await projects.set(name, fields));
412
+ } catch (e) {
413
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_set", errorMessage: e.message }, null, 2) }], isError: true };
414
+ }
415
+ }
416
+ );
417
+
418
+ server.registerTool(
419
+ "project_remove",
420
+ {
421
+ title: "Supprimer un projet du registre",
422
+ inputSchema: z.object({ name: z.string() })
423
+ },
424
+ async (params) => {
425
+ try {
426
+ guardWritable('project_remove');
427
+ return jsonResult(await projects.remove(params.name));
428
+ } catch (e) {
429
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_remove", errorMessage: e.message }, null, 2) }], isError: true };
430
+ }
431
+ }
432
+ );
433
+
434
+ server.registerTool(
435
+ "project_resolve",
436
+ {
437
+ title: "Résoudre un projet en sources local/remote",
438
+ description: "Retourne { local, remote, ignore, runtime } prêts pour file_* / project_diff.",
439
+ inputSchema: z.object({
440
+ name: z.string(),
441
+ role: z.string().optional().describe("Rôle serveur (prod, staging…). Défaut: prod ou premier.")
442
+ })
443
+ },
444
+ async (params) => {
445
+ try {
446
+ return jsonResult(await projects.resolve(params.name, params.role || null));
447
+ } catch (e) {
448
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_resolve", errorMessage: e.message }, null, 2) }], isError: true };
449
+ }
450
+ }
451
+ );
452
+
453
+ // --- PROJECT DIFF (C) ---
454
+
455
+ server.registerTool(
456
+ "project_diff",
457
+ {
458
+ title: "Diff local ↔ remote d'un projet enregistré",
459
+ description: "Compare le path local et le path remote du projet (ignorePatterns du registre). includeDiff pour le détail.",
460
+ inputSchema: z.object({
461
+ name: z.string(),
462
+ role: z.string().optional(),
463
+ includeDiff: z.boolean().optional().default(false),
464
+ compact: z.boolean().optional()
465
+ })
466
+ },
467
+ async (params) => {
468
+ try {
469
+ const resolved = await projects.resolve(params.name, params.role || null);
470
+ if (!resolved.local || !resolved.remote) {
471
+ throw new Error("Le projet doit avoir local.path ET servers.<role>.path pour project_diff.");
472
+ }
473
+ const result = await diffEngine.diffFolders(resolved.local, resolved.remote, {
474
+ ignorePatterns: resolved.ignore,
475
+ includeDiff: params.includeDiff === true
476
+ });
477
+ const summary = {
478
+ project: params.name,
479
+ role: resolved.role,
480
+ local: resolved.local,
481
+ remote: resolved.remote,
482
+ stats: result.stats,
483
+ only_in_local: result.only_in_source1?.length ?? 0,
484
+ only_in_remote: result.only_in_source2?.length ?? 0,
485
+ identical: result.identical?.length ?? 0,
486
+ modified: result.modified?.length ?? 0,
487
+ modified_paths: (result.modified || []).slice(0, 30).map(m => m.path),
488
+ only_local_sample: (result.only_in_source1 || []).slice(0, 20),
489
+ only_remote_sample: (result.only_in_source2 || []).slice(0, 20)
490
+ };
491
+ if (params.includeDiff) summary.detail = result;
492
+ return jsonResult(summary, params);
493
+ } catch (e) {
494
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "project_diff", errorMessage: e.message }, null, 2) }], isError: true };
495
+ }
496
+ }
497
+ );
498
+
499
+ // --- WORK SESSIONS (B) ---
500
+
501
+ server.registerTool(
502
+ "work_start",
503
+ {
504
+ title: "Démarrer une session de travail",
505
+ description: "Ouvre un journal d'intervention (option snapshot). Retourne sessionId. Fermer avec work_end (met à jour server_note).",
506
+ inputSchema: z.object({
507
+ alias: z.string().optional(),
508
+ project: z.string().optional(),
509
+ tag: z.string().optional(),
510
+ message: z.string().optional(),
511
+ snapshot: z.boolean().optional().default(false).describe("Snapshot remote avant travail"),
512
+ paths: z.array(z.string()).optional().describe("Paths à snapshotter si snapshot:true")
513
+ })
514
+ },
515
+ async (params) => {
516
+ try {
517
+ guardWritable('work_start');
518
+ if (params.alias) await guardAliasWritable(params.alias, 'work_start');
519
+ const session = await workSession.start(params);
520
+ return jsonResult(session);
521
+ } catch (e) {
522
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "work_start", errorMessage: e.message }, null, 2) }], isError: true };
523
+ }
524
+ }
525
+ );
526
+
527
+ server.registerTool(
528
+ "work_log",
529
+ {
530
+ title: "Logger un événement dans une work session",
531
+ inputSchema: z.object({
532
+ id: z.string(),
533
+ type: z.string().describe("ex: file_edit, task_exec, note"),
534
+ detail: z.string().optional(),
535
+ path: z.string().optional()
536
+ })
537
+ },
538
+ async (params) => {
539
+ try {
540
+ guardWritable('work_log');
541
+ const r = await workSession.log(params.id, {
542
+ type: params.type,
543
+ detail: params.detail,
544
+ path: params.path
545
+ });
546
+ return jsonResult(r);
547
+ } catch (e) {
548
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "work_log", errorMessage: e.message }, null, 2) }], isError: true };
549
+ }
550
+ }
551
+ );
552
+
553
+ server.registerTool(
554
+ "work_list",
555
+ {
556
+ title: "Lister les work sessions",
557
+ inputSchema: z.object({
558
+ includeHistory: z.boolean().optional().default(false)
559
+ })
560
+ },
561
+ async (params) => jsonResult(await workSession.list({ includeHistory: params.includeHistory }))
562
+ );
563
+
564
+ server.registerTool(
565
+ "work_end",
566
+ {
567
+ title: "Clôturer une work session",
568
+ description: "Ferme la session et met à jour last_intervention sur la note serveur (note:true).",
569
+ inputSchema: z.object({
570
+ id: z.string(),
571
+ summary: z.string().optional(),
572
+ note: z.boolean().optional().default(true)
573
+ })
574
+ },
575
+ async (params) => {
576
+ try {
577
+ guardWritable('work_end');
578
+ return jsonResult(await workSession.end(params.id, {
579
+ summary: params.summary,
580
+ note: params.note !== false
581
+ }));
582
+ } catch (e) {
583
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "work_end", errorMessage: e.message }, null, 2) }], isError: true };
584
+ }
585
+ }
586
+ );
587
+
588
+ // --- INVENTORY (D) ---
589
+
590
+ server.registerTool(
591
+ "server_inventory",
592
+ {
593
+ title: "Inventaire léger d'un serveur (cache 10 min)",
594
+ description: "hostname, disk, mem, pm2, docker, top-level $HOME, présence tailscale. force:true pour rafraîchir.",
595
+ inputSchema: z.object({
596
+ alias: z.string(),
597
+ force: z.boolean().optional().default(false),
598
+ compact: z.boolean().optional()
599
+ })
600
+ },
601
+ async (params) => {
602
+ try {
603
+ const inv = await inventory.get(params.alias, { force: params.force === true });
604
+ return jsonResult(inv, params);
605
+ } catch (e) {
606
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "server_inventory", errorMessage: e.message }, null, 2) }], isError: true };
607
+ }
608
+ }
609
+ );
610
+
611
+ // --- SSH TRUST / AUTHORIZE KEY (v11.6) ---
612
+
613
+ server.registerTool(
614
+ "ssh_authorize_key",
615
+ {
616
+ title: "Autoriser une pubkey SSH sur un serveur (trust)",
617
+ description: "Ajoute une clé PUBLIQUE dans authorized_keys d'un serveur. source: string|local_path|.pub|alias (dérive .pub depuis keyPath). Ne copie JAMAIS de private key. dry_run:true par défaut.",
618
+ inputSchema: z.object({
619
+ target_alias: z.string().describe("Serveur où écrire authorized_keys"),
620
+ source: z.object({
621
+ type: z.enum(['string', 'local_path', 'alias']),
622
+ pubkey: z.string().optional(),
623
+ path: z.string().optional(),
624
+ alias: z.string().optional()
625
+ }).describe("Origine de la pubkey"),
626
+ user: z.string().optional().describe("User distant (défaut: user SSH de l'alias)"),
627
+ authorized_keys_path: z.string().optional(),
628
+ comment: z.string().optional(),
629
+ dry_run: z.boolean().optional().default(true),
630
+ force: z.boolean().optional().default(false)
631
+ })
632
+ },
633
+ async (params) => {
634
+ try {
635
+ if (!params.dry_run) {
636
+ await guardAliasWritable(params.target_alias, 'ssh_authorize_key');
637
+ }
638
+ const result = await sshTrust.authorizeKey({
639
+ target_alias: params.target_alias,
640
+ source: params.source,
641
+ user: params.user,
642
+ authorized_keys_path: params.authorized_keys_path,
643
+ comment: params.comment,
644
+ dryRun: params.dry_run !== false,
645
+ force: params.force === true
646
+ });
647
+ return jsonResult(result);
648
+ } catch (e) {
649
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "ssh_authorize_key", errorMessage: e.message }, null, 2) }], isError: true };
650
+ }
651
+ }
652
+ );
653
+
162
654
  // --- OUTILS DE SÉCURITÉ / POLICIES ---
163
655
 
164
656
  server.registerTool(
@@ -226,6 +718,7 @@ server.registerTool(
226
718
  },
227
719
  async (params) => {
228
720
  try {
721
+ guardWritable('api_add');
229
722
  const { alias, ...apiConfig } = params;
230
723
  const result = await apis.addApi(alias, apiConfig);
231
724
  return { content: [{ type: "text", text: result.message }] };
@@ -248,7 +741,8 @@ server.registerTool(
248
741
  inputSchema: z.object({})
249
742
  },
250
743
  async () => {
251
- const apiList = await apis.listApis();
744
+ // Ne jamais renvoyer api_key / htpasswd_pass en clair aux agents
745
+ const apiList = utils.redactSensitiveObject(await apis.listApis());
252
746
  return { content: [{ type: "text", text: JSON.stringify(apiList, null, 2) }] };
253
747
  }
254
748
  );
@@ -264,6 +758,7 @@ server.registerTool(
264
758
  },
265
759
  async (params) => {
266
760
  try {
761
+ guardWritable('api_remove');
267
762
  const result = await apis.removeApi(params.alias);
268
763
  return { content: [{ type: "text", text: result.message }] };
269
764
  } catch (e) {
@@ -456,7 +951,7 @@ server.registerTool(
456
951
  },
457
952
  async (params) => {
458
953
  try {
459
- const cmd = `sudo fail2ban-client status ${params.jail || ''}`.trim();
954
+ const cmd = params.jail ? `sudo fail2ban-client status ${utils.escapeShellArg(params.jail)}` : 'sudo fail2ban-client status';
460
955
  const job = queue.addJob({
461
956
  type: 'ssh',
462
957
  alias: params.alias,
@@ -495,7 +990,8 @@ async function waitForJobCompletion(jobId, timeout) {
495
990
  resolve(null);
496
991
  return;
497
992
  }
498
- if (job.status === 'completed' || job.status === 'failed') {
993
+ // completed | failed | partial | crashed = terminal (plus d'attente)
994
+ if (utils.isTerminalJobStatus(job.status)) {
499
995
  clearInterval(interval);
500
996
  resolve(job);
501
997
  } else if (timeout > 0 && Date.now() - startTime > timeout) {
@@ -520,14 +1016,22 @@ server.registerTool(
520
1016
  inputSchema: z.object({
521
1017
  alias: z.string().describe("Alias du serveur cible."),
522
1018
  direction: z.enum(['upload', 'download', 'server_to_server']),
523
- local: z.string().optional().describe("Chemin absolu local (upload/download)."),
524
- remote: z.string().describe("Chemin absolu distant."),
1019
+ local: z.string().optional().describe("Chemin local (upload/download) ou chemin distant SOURCE pour server_to_server."),
1020
+ remote: z.string().describe("Chemin distant cible (upload/server_to_server) ou source distante (download)."),
525
1021
  source_alias: z.string().optional().describe("Alias source (requis si direction='server_to_server')."),
526
1022
  force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
527
1023
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
528
1024
  })
529
1025
  },
530
1026
  async (params) => {
1027
+ try {
1028
+ await guardTransferWritable(params, 'task_transfer');
1029
+ if (params.direction === 'server_to_server' && !params.source_alias) {
1030
+ throw new Error("source_alias requis pour server_to_server.");
1031
+ }
1032
+ } catch (e) {
1033
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_transfer", errorMessage: e.message }, null, 2) }], isError: true };
1034
+ }
531
1035
  const job = queue.addJob({ type: 'sftp', ...params, status: 'pending' });
532
1036
  history.logTask(job);
533
1037
  sftp.executeTransfer(job.id);
@@ -550,45 +1054,75 @@ server.registerTool(
550
1054
  "task_exec",
551
1055
  {
552
1056
  title: "Exécuter une commande à distance (SSH) — multi-serveur supporté",
553
- description: `Exécute une commande SSH. Si la tâche prend moins de ${config.syncTimeout / 1000}s, le résultat est direct. Sinon, elle passe en arrière-plan. Supporte alias unique ("vps1"), multiple (["vps1","vps2"]) ou "all" pour tout le parc.`,
1057
+ description: `Exécute une commande SSH. Si la tâche prend moins de ${config.syncTimeout / 1000}s, le résultat est direct. Sinon, elle passe en arrière-plan. Supporte alias unique, tableau, "all", "group:nom" ou nom de groupe. dry_run:true sur commande destructive = preview sans exécuter.`,
554
1058
  inputSchema: z.object({
555
- alias: z.union([z.string(), z.array(z.string())]).describe("Alias du serveur cible, tableau d'alias, ou 'all' pour tout le parc."),
1059
+ alias: z.union([z.string(), z.array(z.string())]).describe("Alias, tableau, 'all', 'group:oci' ou nom de groupe."),
556
1060
  cmd: z.string().describe("La commande complète à exécuter."),
557
1061
  timeout: z.number().optional().describe("Timeout en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
558
1062
  skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist)."),
1063
+ dry_run: z.boolean().optional().default(false).describe("Si true (ou commande destructive sans force), retourne l'analyse sans exécuter."),
1064
+ force: z.boolean().optional().default(false).describe("Exécuter même si la commande est détectée destructive."),
559
1065
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
560
1066
  })
561
1067
  },
562
1068
  async (params) => {
563
- const aliases = Array.isArray(params.alias) ? params.alias
564
- : params.alias === 'all' ? Object.keys(await servers.listServers())
565
- : [params.alias];
1069
+ try {
1070
+ guardWritable('task_exec');
1071
+ const aliases = await groups.resolveAliases(params.alias);
1072
+ // RO par alias
1073
+ for (const a of aliases) {
1074
+ if (await servers.isAliasReadOnly(a)) {
1075
+ throw new Error(`Alias '${a}' est readonly:true — task_exec refusé.`);
1076
+ }
1077
+ }
1078
+ const destructive = utils.isDestructiveCommand(params.cmd);
566
1079
 
567
- if (aliases.length === 1) {
568
- const job = queue.addJob({ type: 'ssh', alias: aliases[0], cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' });
569
- history.logTask(job);
570
- ssh.executeCommand(job.id);
571
- const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
572
- if (finalJob) {
573
- return { content: [{ type: "text", text: `Résultat direct (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
574
- } else {
575
- return { content: [{ type: "text", text: buildAsyncMessage(job, "d'exécution") }] };
1080
+ if (params.dry_run || (destructive && !params.force)) {
1081
+ return {
1082
+ content: [{
1083
+ type: "text",
1084
+ text: JSON.stringify({
1085
+ dry_run: true,
1086
+ would_execute: !destructive || params.force,
1087
+ destructive,
1088
+ aliases,
1089
+ cmd: params.cmd,
1090
+ note: destructive && !params.force
1091
+ ? "Commande détectée destructive. Relancez avec force:true pour exécuter, ou dry_run:true pour confirmer l'intention."
1092
+ : "Preview dry_run — aucune commande envoyée."
1093
+ }, null, 2)
1094
+ }]
1095
+ };
576
1096
  }
577
- }
578
1097
 
579
- const jobs = aliases.map(alias =>
580
- queue.addJob({ type: 'ssh', alias, cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' })
581
- );
582
- jobs.forEach(j => { history.logTask(j); ssh.executeCommand(j.id); });
1098
+ if (aliases.length === 1) {
1099
+ const job = queue.addJob({ type: 'ssh', alias: aliases[0], cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' });
1100
+ history.logTask(job);
1101
+ ssh.executeCommand(job.id);
1102
+ const finalJob = await waitForJobCompletion(job.id, config.syncTimeout);
1103
+ if (finalJob) {
1104
+ return { content: [{ type: "text", text: `Résultat direct (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
1105
+ } else {
1106
+ return { content: [{ type: "text", text: buildAsyncMessage(job, "d'exécution") }] };
1107
+ }
1108
+ }
1109
+
1110
+ const jobs = aliases.map(alias =>
1111
+ queue.addJob({ type: 'ssh', alias, cmd: params.cmd, timeout: params.timeout, skip_policy: params.skip_policy, status: 'pending' })
1112
+ );
1113
+ jobs.forEach(j => { history.logTask(j); ssh.executeCommand(j.id); });
583
1114
 
584
- const results = await Promise.all(jobs.map(j => waitForJobCompletion(j.id, config.syncTimeout)));
585
- const output = results.map((r, i) => {
586
- if (!r) return `[${aliases[i]}] Timeout ou erreur`;
587
- if (r.status === 'failed') return `[${aliases[i]}] ERREUR: ${r.error}`;
588
- return `[${aliases[i]}] OK\n${r.output || '(vide)'}`;
589
- }).join('\n\n---\n\n');
1115
+ const results = await Promise.all(jobs.map(j => waitForJobCompletion(j.id, config.syncTimeout)));
1116
+ const output = results.map((r, i) => {
1117
+ if (!r) return `[${aliases[i]}] Timeout ou erreur`;
1118
+ if (r.status === 'failed') return `[${aliases[i]}] ERREUR: ${r.error}`;
1119
+ return `[${aliases[i]}] OK\n${r.output || '(vide)'}`;
1120
+ }).join('\n\n---\n\n');
590
1121
 
591
- return { content: [{ type: "text", text: `Résultats multi-serveur (${aliases.length} cibles):\n\n${output}` }] };
1122
+ return { content: [{ type: "text", text: `Résultats multi-serveur (${aliases.length} cibles):\n\n${output}` }] };
1123
+ } catch (e) {
1124
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_exec", errorMessage: e.message }, null, 2) }], isError: true };
1125
+ }
592
1126
  }
593
1127
  );
594
1128
 
@@ -606,13 +1140,20 @@ server.registerTool(
606
1140
  "task_queue",
607
1141
  {
608
1142
  title: "Voir la file d'attente des tâches",
609
- description: "Affiche le statut de toutes les tâches, avec des rappels pour les tâches longues.",
610
- inputSchema: z.object({})
1143
+ description: "Affiche la queue récente. Filtrable par statut et paginable pour éviter de déverser des centaines de jobs dans le contexte.",
1144
+ inputSchema: z.object({
1145
+ status: z.enum(['pending','running','completed','failed','crashed','partial']).optional().describe("Filtre optionnel par statut."),
1146
+ limit: z.number().int().min(1).max(500).optional().default(100).describe("Nombre maximum de jobs retournés."),
1147
+ compact: z.boolean().optional().default(true).describe("Tronque les gros champs output/stderr/diff. Défaut true.")
1148
+ })
611
1149
  },
612
- async () => {
1150
+ async (params) => {
613
1151
  const queueState = queue.getQueue();
614
- const displayQueue = Object.values(queueState).map(formatJobForDisplay);
615
- return { content: [{ type: "text", text: JSON.stringify(displayQueue, null, 2) }] };
1152
+ let displayQueue = Object.values(queueState).map(formatJobForDisplay);
1153
+ if (params.status) displayQueue = displayQueue.filter(j => j.status === params.status);
1154
+ displayQueue.sort((a,b) => new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0));
1155
+ displayQueue = displayQueue.slice(0, params.limit);
1156
+ return jsonResult({ jobs: displayQueue, count: displayQueue.length, total: Object.keys(queueState).length }, params);
616
1157
  }
617
1158
  );
618
1159
 
@@ -638,14 +1179,18 @@ server.registerTool(
638
1179
  "task_history",
639
1180
  {
640
1181
  title: "Consulter l'historique des tâches",
641
- description: "Affiche les dernières tâches lancées. Peut être filtré par alias.",
1182
+ description: "Historique récent des tâches, filtrable par alias et paginable. Utiliser task_status pour le détail complet d'un job précis.",
642
1183
  inputSchema: z.object({
643
- alias: z.string().optional().describe("Filtre l'historique pour ne montrer que les tâches d'un alias spécifique.")
1184
+ alias: z.string().optional().describe("Filtre par alias serveur."),
1185
+ limit: z.number().int().min(1).max(500).optional().default(100).describe("Nombre maximum d'entrées."),
1186
+ offset: z.number().int().min(0).optional().default(0).describe("Décalage pour pagination."),
1187
+ compact: z.boolean().optional().default(true).describe("Tronque les gros champs si présents.")
644
1188
  })
645
1189
  },
646
1190
  async (params) => {
647
- const historyLogs = await history.getHistory(params);
648
- return { content: [{ type: "text", text: JSON.stringify(historyLogs, null, 2) }] };
1191
+ const historyLogs = await history.getHistory({ alias: params.alias });
1192
+ const page = historyLogs.slice(params.offset, params.offset + params.limit);
1193
+ return jsonResult({ entries: page, count: page.length, total: historyLogs.length, offset: params.offset, limit: params.limit }, params);
649
1194
  }
650
1195
  );
651
1196
 
@@ -658,16 +1203,27 @@ server.registerTool(
658
1203
  description: "Lance des transferts SFTP multiples avec support de patterns glob (*, ?, []).",
659
1204
  inputSchema: z.object({
660
1205
  alias: z.string().describe("Alias du serveur cible."),
661
- direction: z.enum(['upload', 'download']),
1206
+ direction: z.enum(['upload', 'download', 'server_to_server']),
1207
+ source_alias: z.string().optional().describe("Alias source commun pour server_to_server (surchargeable par fichier)."),
662
1208
  files: z.array(z.object({
663
- local: z.string().describe("Chemin local ou pattern glob (ex: /home/*.txt)"),
664
- remote: z.string().describe("Chemin distant")
665
- })).describe("Liste des fichiers à transférer"),
1209
+ local: z.string().describe("Chemin local/pattern, ou chemin distant SOURCE en server_to_server."),
1210
+ remote: z.string().describe("Chemin distant cible."),
1211
+ source_alias: z.string().optional().describe("Alias source spécifique à ce fichier en server_to_server.")
1212
+ })).min(1).describe("Liste des fichiers/dossiers à transférer"),
666
1213
  force: z.boolean().optional().default(false).describe("Écraser les fichiers existants sans confirmation."),
667
1214
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
668
1215
  })
669
1216
  },
670
1217
  async (params) => {
1218
+ try {
1219
+ await guardTransferWritable(params, 'task_transfer_multi');
1220
+ if (params.direction === 'server_to_server') {
1221
+ const missing = params.files.some(f => !(f.source_alias || params.source_alias));
1222
+ if (missing) throw new Error("source_alias requis (global ou par fichier) pour server_to_server.");
1223
+ }
1224
+ } catch (e) {
1225
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_transfer_multi", errorMessage: e.message }, null, 2) }], isError: true };
1226
+ }
671
1227
  const job = queue.addJob({
672
1228
  type: 'sftp',
673
1229
  ...params,
@@ -702,6 +1258,8 @@ server.registerTool(
702
1258
  })
703
1259
  },
704
1260
  async (params) => {
1261
+ try { await guardAliasWritable(params.alias, 'task_exec_interactive'); }
1262
+ catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_exec_interactive", errorMessage: e.message }, null, 2) }], isError: true }; }
705
1263
  const job = queue.addJob({
706
1264
  type: 'ssh',
707
1265
  ...params,
@@ -712,7 +1270,10 @@ server.registerTool(
712
1270
  history.logTask(job);
713
1271
  ssh.executeCommand(job.id);
714
1272
 
715
- const finalJob = await waitForJobCompletion(job.id, params.timeout || config.syncTimeout);
1273
+ // Hybrid wait toujours en ms (config.syncTimeout). params.timeout = timeout COMMANDE en secondes (job).
1274
+ // Ancien bug : params.timeout (secondes) passé tel quel → attente de 30ms au lieu de 30s.
1275
+ const waitMs = utils.toWaitTimeoutMs(undefined, config.syncTimeout);
1276
+ const finalJob = await waitForJobCompletion(job.id, waitMs);
716
1277
  if (finalJob) {
717
1278
  return { content: [{ type: "text", text: `Résultat commande interactive (tâche ${finalJob.id}):\n${finalJob.output || JSON.stringify(finalJob, null, 2)}` }] };
718
1279
  } else {
@@ -738,13 +1299,17 @@ server.registerTool(
738
1299
  ])).min(1).describe("Liste des commandes à exécuter en séquence (minimum 1)."),
739
1300
  continueOnError: z.boolean().optional().default(false).describe("Continuer même si une commande échoue."),
740
1301
  timeout: z.number().optional().describe("Timeout global en secondes. 0 = pas de limite. Défaut: 600s (10 min)."),
1302
+ skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist) pour chaque étape."),
741
1303
  rappel: z.number().optional().describe("Définit un rappel en secondes.")
742
1304
  })
743
1305
  },
744
1306
  async (params) => {
1307
+ try { await guardAliasWritable(params.alias, 'task_exec_sequence'); }
1308
+ catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_exec_sequence", errorMessage: e.message }, null, 2) }], isError: true }; }
745
1309
  const job = queue.addJob({
746
1310
  type: 'ssh_sequence',
747
1311
  ...params,
1312
+ skip_policy: params.skip_policy,
748
1313
  status: 'pending'
749
1314
  });
750
1315
  history.logTask(job);
@@ -792,6 +1357,12 @@ server.registerTool(
792
1357
  }
793
1358
  );
794
1359
 
1360
+ function dispatchJob(job) {
1361
+ if (job.type === 'sftp') sftp.executeTransfer(job.id);
1362
+ else if (job.type === 'ssh') ssh.executeCommand(job.id);
1363
+ else if (job.type === 'ssh_sequence') ssh.executeCommandSequence(job.id);
1364
+ }
1365
+
795
1366
  server.registerTool(
796
1367
  "task_retry",
797
1368
  {
@@ -803,17 +1374,11 @@ server.registerTool(
803
1374
  },
804
1375
  async (params) => {
805
1376
  try {
1377
+ guardWritable('task_retry');
1378
+ const originalJob = queue.getJob(params.id);
1379
+ await guardRetryJobWritable(originalJob, 'task_retry');
806
1380
  const newJob = await queue.retryJob(params.id);
807
-
808
- // Relancer selon le type
809
- if (newJob.type === 'sftp') {
810
- sftp.executeTransfer(newJob.id);
811
- } else if (newJob.type === 'ssh') {
812
- ssh.executeCommand(newJob.id);
813
- } else if (newJob.type === 'ssh_sequence') {
814
- ssh.executeCommandSequence(newJob.id);
815
- }
816
-
1381
+ dispatchJob(newJob);
817
1382
  return { content: [{ type: "text", text: `Tâche ${params.id} relancée avec le nouvel ID: ${newJob.id}` }] };
818
1383
  } catch (e) {
819
1384
  const errorPayload = {
@@ -826,6 +1391,82 @@ server.registerTool(
826
1391
  }
827
1392
  );
828
1393
 
1394
+ server.registerTool(
1395
+ "task_retry_all",
1396
+ {
1397
+ title: "Relancer toutes les tâches crashed/failed",
1398
+ description: "Relance en masse les jobs retryables (crashed et/ou failed). dry_run:true pour lister sans exécuter.",
1399
+ inputSchema: z.object({
1400
+ status: z.enum(['crashed', 'failed', 'both']).optional().default('crashed').describe("Filtrer par statut"),
1401
+ dry_run: z.boolean().optional().default(false),
1402
+ limit: z.number().optional().default(20).describe("Max de jobs à relancer")
1403
+ })
1404
+ },
1405
+ async (params) => {
1406
+ try {
1407
+ if (!params.dry_run) guardWritable('task_retry_all');
1408
+ const filter = params.status === 'both' ? null : params.status;
1409
+ let jobs = queue.getRetryableJobs(filter);
1410
+ if (params.status === 'both') {
1411
+ jobs = queue.getRetryableJobs(null);
1412
+ }
1413
+ jobs = jobs.slice(0, params.limit || 20);
1414
+ if (params.dry_run) {
1415
+ return {
1416
+ content: [{
1417
+ type: "text",
1418
+ text: JSON.stringify({
1419
+ dry_run: true,
1420
+ count: jobs.length,
1421
+ jobs: jobs.map(j => ({ id: j.id, type: j.type, alias: j.alias, status: j.status, cmd: (j.cmd || '').slice(0, 80) }))
1422
+ }, null, 2)
1423
+ }]
1424
+ };
1425
+ }
1426
+ const results = [];
1427
+ for (const j of jobs) {
1428
+ try {
1429
+ await guardRetryJobWritable(j, 'task_retry_all');
1430
+ const newJob = await queue.retryJob(j.id);
1431
+ dispatchJob(newJob);
1432
+ results.push({ from: j.id, to: newJob.id, ok: true });
1433
+ } catch (e) {
1434
+ results.push({ from: j.id, ok: false, error: e.message });
1435
+ }
1436
+ }
1437
+ return { content: [{ type: "text", text: JSON.stringify({ retried: results.length, results }, null, 2) }] };
1438
+ } catch (e) {
1439
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_retry_all", errorMessage: e.message }, null, 2) }], isError: true };
1440
+ }
1441
+ }
1442
+ );
1443
+
1444
+ server.registerTool(
1445
+ "task_purge",
1446
+ {
1447
+ title: "Purger les jobs terminés/crashés de la queue",
1448
+ description: "Supprime de la queue les jobs crashed/failed/completed. dry_run:true pour compter sans supprimer. olderThanDays optionnel.",
1449
+ inputSchema: z.object({
1450
+ status: z.enum(['crashed', 'failed', 'completed', 'all_terminal']).optional().default('crashed'),
1451
+ olderThanDays: z.number().optional().default(0).describe("0 = tous les jobs du statut, sinon âge minimum en jours"),
1452
+ dry_run: z.boolean().optional().default(true).describe("Défaut true (sécurité) — dry_run:false pour purger vraiment")
1453
+ })
1454
+ },
1455
+ async (params) => {
1456
+ try {
1457
+ if (!params.dry_run) guardWritable('task_purge');
1458
+ const result = queue.purgeJobs({
1459
+ status: params.status,
1460
+ olderThanDays: params.olderThanDays || 0,
1461
+ dryRun: params.dry_run !== false
1462
+ });
1463
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1464
+ } catch (e) {
1465
+ return { content: [{ type: "text", text: JSON.stringify({ toolName: "task_purge", errorMessage: e.message }, null, 2) }], isError: true };
1466
+ }
1467
+ }
1468
+ );
1469
+
829
1470
  server.registerTool(
830
1471
  "task_logs",
831
1472
  {
@@ -834,7 +1475,8 @@ server.registerTool(
834
1475
  inputSchema: z.object({
835
1476
  level: z.enum(['error', 'warn', 'info', 'debug']).optional().describe("Filtrer par niveau de log."),
836
1477
  search: z.string().optional().describe("Rechercher dans les messages."),
837
- limit: z.number().optional().default(50).describe("Nombre de logs à afficher.")
1478
+ limit: z.number().int().min(1).max(500).optional().default(50).describe("Nombre de logs à afficher (1..500)."),
1479
+ compact: z.boolean().optional().default(true).describe("Tronque les messages exceptionnellement volumineux.")
838
1480
  })
839
1481
  },
840
1482
  async (params) => {
@@ -842,7 +1484,7 @@ server.registerTool(
842
1484
  level: params.level,
843
1485
  search: params.search
844
1486
  }).slice(-params.limit);
845
- return { content: [{ type: "text", text: JSON.stringify(logs, null, 2) }] };
1487
+ return jsonResult({ logs, count: logs.length }, params);
846
1488
  }
847
1489
  );
848
1490
 
@@ -856,13 +1498,13 @@ server.registerTool(
856
1498
  inputSchema: z.object({
857
1499
  alias: z.string().describe("Alias du serveur cible."),
858
1500
  app: z.string().optional().describe("Nom de l'application PM2 (optionnel, toutes par défaut)."),
859
- lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
1501
+ lines: z.number().int().min(1).max(100000).optional().default(100).describe("Nombre de lignes à récupérer (1..100000)."),
860
1502
  errors: z.boolean().optional().default(false).describe("Récupérer uniquement les erreurs (stderr).")
861
1503
  })
862
1504
  },
863
1505
  async (params) => {
864
1506
  let cmd = 'pm2 logs';
865
- if (params.app) cmd += ` ${params.app}`;
1507
+ if (params.app) cmd += ` ${utils.escapeShellArg(params.app)}`;
866
1508
  if (params.errors) cmd += ' --err';
867
1509
  cmd += ` --lines ${params.lines} --nostream`;
868
1510
 
@@ -886,14 +1528,14 @@ server.registerTool(
886
1528
  inputSchema: z.object({
887
1529
  alias: z.string().describe("Alias du serveur cible."),
888
1530
  container: z.string().describe("Nom ou ID du container Docker."),
889
- lines: z.number().optional().default(100).describe("Nombre de lignes à récupérer."),
1531
+ lines: z.number().int().min(1).max(100000).optional().default(100).describe("Nombre de lignes à récupérer (1..100000)."),
890
1532
  since: z.string().optional().describe("Logs depuis (ex: '5m', '1h', '2024-01-01')."),
891
1533
  timestamps: z.boolean().optional().default(false).describe("Afficher les timestamps.")
892
1534
  })
893
1535
  },
894
1536
  async (params) => {
895
1537
  let cmd = `docker logs --tail ${params.lines}`;
896
- if (params.since) cmd += ` --since ${params.since}`;
1538
+ if (params.since) cmd += ` --since ${utils.escapeShellArg(params.since)}`;
897
1539
  if (params.timestamps) cmd += ' --timestamps';
898
1540
  cmd += ` ${utils.escapeShellArg(params.container)}`;
899
1541
 
@@ -918,7 +1560,7 @@ server.registerTool(
918
1560
  inputSchema: z.object({
919
1561
  alias: z.string().describe("Alias du serveur cible."),
920
1562
  filepath: z.string().describe("Chemin absolu du fichier à lire."),
921
- lines: z.number().optional().default(50).describe("Nombre de lignes à afficher.")
1563
+ lines: z.number().int().min(1).max(100000).optional().default(50).describe("Nombre de lignes à afficher (1..100000).")
922
1564
  })
923
1565
  },
924
1566
  async (params) => {
@@ -1182,7 +1824,7 @@ server.registerTool(
1182
1824
  return { content: [{ type: "text", text: detail }] };
1183
1825
  }
1184
1826
 
1185
- const guide = `=== GUIDE MCP ORCHESTRATOR v11.3.0 ===
1827
+ const guide = `=== GUIDE MCP ORCHESTRATOR v${ORCH_VERSION} ===
1186
1828
 
1187
1829
  OUTILS DISPONIBLES (${allTools.length}):
1188
1830
  ${allTools.map(t => ` ${t.name}: ${t.desc}`).join('\n')}
@@ -1287,6 +1929,12 @@ server.registerTool(
1287
1929
  },
1288
1930
  async (params) => {
1289
1931
  try {
1932
+ if (!params.dryRun) {
1933
+ guardWritable('file_write');
1934
+ if (params.source?.type === 'remote' && params.source.alias) {
1935
+ await guardAliasWritable(params.source.alias, 'file_write');
1936
+ }
1937
+ }
1290
1938
  const source = resolveSource(params.source);
1291
1939
  const result = await fileOps.writeFile(source, params.content, params.encoding, {
1292
1940
  createDirs: params.createDirs,
@@ -1326,6 +1974,12 @@ server.registerTool(
1326
1974
  },
1327
1975
  async (params) => {
1328
1976
  try {
1977
+ if (!params.dryRun) {
1978
+ guardWritable('file_edit');
1979
+ if (params.source?.type === 'remote' && params.source.alias) {
1980
+ await guardAliasWritable(params.source.alias, 'file_edit');
1981
+ }
1982
+ }
1329
1983
  const source = resolveSource(params.source);
1330
1984
  const result = await fileOps.editFile(source, {
1331
1985
  oldString: params.oldString,
@@ -1516,16 +2170,23 @@ server.registerTool(
1516
2170
  "shell_exec",
1517
2171
  {
1518
2172
  title: "Exécuter dans une session shell persistante",
1519
- description: "Exécute une commande dans une session shell existante (créée via shell_create). L'état (répertoire courant, variables) PERSISTE entre les appels. Retourne { output, exitCode, timedOut }. Une seule commande à la fois par session.",
2173
+ description: "Exécute une commande dans une session shell existante (créée via shell_create). L'état (répertoire courant, variables) PERSISTE entre les appels. Retourne { output, exitCode, timedOut }. Une seule commande à la fois par session. Respecte la blocklist (skip_policy:true pour forcer).",
1520
2174
  inputSchema: z.object({
1521
2175
  sessionId: z.string().describe("ID de session obtenu via shell_create"),
1522
2176
  command: z.string().describe("Commande à exécuter"),
1523
- timeout: z.number().optional().describe("Timeout en secondes pour cette commande (0=infini)")
2177
+ timeout: z.number().optional().describe("Timeout en secondes pour cette commande (0=infini)"),
2178
+ skip_policy: z.boolean().optional().default(false).describe("Ignorer la politique de sécurité (blocklist).")
1524
2179
  })
1525
2180
  },
1526
2181
  async (params) => {
1527
2182
  try {
1528
- const result = await shellSessions.execInSession(params.sessionId, params.command, params.timeout);
2183
+ const sessionInfo = shellSessions.getSessionInfo(params.sessionId);
2184
+ if (!sessionInfo) throw new Error(`Session '${params.sessionId}' introuvable.`);
2185
+ await guardAliasWritable(sessionInfo.alias, 'shell_exec');
2186
+ const result = await shellSessions.execInSession(params.sessionId, params.command, {
2187
+ timeoutSec: params.timeout,
2188
+ skip_policy: params.skip_policy
2189
+ });
1529
2190
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1530
2191
  } catch (e) {
1531
2192
  const errorPayload = { toolName: "shell_exec", errorCode: "SHELL_EXEC_ERROR", errorMessage: e.message };
@@ -1699,7 +2360,12 @@ server.registerTool(
1699
2360
  if (params.target.type === 'remote' && !params.target.alias) {
1700
2361
  throw new Error("alias requis quand type='remote'.");
1701
2362
  }
1702
- // Sécurité : restauration réelle bloquée sans force explicite
2363
+ // Sécurité : restauration réelle soumise aux policies globales/alias.
2364
+ if (!params.dryRun) {
2365
+ guardWritable('snapshot_restore');
2366
+ if (params.target.type === 'remote') await guardAliasWritable(params.target.alias, 'snapshot_restore');
2367
+ }
2368
+ // Restauration réelle bloquée sans force explicite
1703
2369
  if (!params.dryRun && !params.force) {
1704
2370
  const errorPayload = {
1705
2371
  toolName: "snapshot_restore",
@@ -1733,6 +2399,7 @@ server.registerTool(
1733
2399
  },
1734
2400
  async (params) => {
1735
2401
  try {
2402
+ guardWritable('snapshot_delete');
1736
2403
  const result = await snapshotManager.deleteSnapshot(params.snapshotId);
1737
2404
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1738
2405
  } catch (e) {
@@ -1761,6 +2428,7 @@ server.registerTool(
1761
2428
  },
1762
2429
  async (params) => {
1763
2430
  try {
2431
+ guardWritable('server_note_set');
1764
2432
  const { alias, ...fields } = params;
1765
2433
  const result = await notes.set(alias, fields);
1766
2434
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
@@ -1823,6 +2491,7 @@ server.registerTool(
1823
2491
  },
1824
2492
  async (params) => {
1825
2493
  try {
2494
+ guardWritable('server_note_remove');
1826
2495
  const result = await notes.remove(params.alias);
1827
2496
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1828
2497
  } catch (e) {
@@ -1832,32 +2501,79 @@ server.registerTool(
1832
2501
  }
1833
2502
  );
1834
2503
 
1835
- // --- VUE SYNTHÉTIQUE DU PARC ---
2504
+ // --- VUE SYNTHÉTIQUE / TOPOLOGIE DU PARC ---
1836
2505
 
1837
2506
  server.registerTool(
1838
2507
  "infra_overview",
1839
2508
  {
1840
- title: "Vue synthétique du parc",
1841
- description: "Vue d'ensemble en un appel : liste des serveurs configurés + leurs notes (rôle, services, avertissements). Idéal en début de session pour situer le contexte sans se perdre.",
1842
- inputSchema: z.object({})
2509
+ title: "Cartographier le parc ou une machine",
2510
+ description: "Vue légère du parc sans alias; avec alias/group:/all, découverte live des ports, Docker/Compose, PM2, systemd et Nginx avec corrélation domaine → proxy → port → conteneur/service.",
2511
+ inputSchema: z.object({
2512
+ alias: z.string().optional().describe("Alias serveur, 'all' ou 'group:<nom>'. Si fourni, active automatiquement la découverte live."),
2513
+ live: z.boolean().optional().describe("Force la découverte live. Sans alias, live:true sonde tout le parc; défaut false pour garder l'appel initial léger."),
2514
+ force: z.boolean().optional().default(false).describe("Ignore le cache topologique court (2 min) et re-sonde les machines."),
2515
+ compact: z.boolean().optional().default(true).describe("Réduit les gros tableaux/chaînes tout en conservant le graphe domaine→service. Défaut true en mode live."),
2516
+ timeoutMs: z.number().int().min(5000).max(60000).optional().default(25000).describe("Timeout par machine pour la découverte live (5s..60s).")
2517
+ }),
2518
+ outputSchema: {
2519
+ mode: z.enum(["registry", "live"]),
2520
+ count: z.number(),
2521
+ generatedAt: z.string(),
2522
+ servers: z.array(z.any())
2523
+ }
1843
2524
  },
1844
- async () => {
2525
+ async (params) => {
1845
2526
  try {
1846
2527
  const serverList = await servers.listServers();
1847
2528
  const allNotes = await notes.list();
1848
- const overview = Object.entries(serverList).map(([alias, cfg]) => {
2529
+ const registry = Object.entries(serverList).map(([alias, cfg]) => {
1849
2530
  const n = allNotes[alias] || {};
1850
2531
  return {
1851
2532
  alias,
1852
2533
  host: cfg.host,
1853
2534
  user: cfg.user,
2535
+ port: utils.resolveSshPort(cfg),
2536
+ readonly: cfg.readonly === true || cfg.readOnly === true,
1854
2537
  description: n.description || null,
1855
2538
  services: n.services || [],
1856
2539
  warnings: n.warnings || [],
2540
+ conventions: n.conventions || [],
1857
2541
  lastIntervention: n.last_intervention || null
1858
2542
  };
1859
2543
  });
1860
- return { content: [{ type: "text", text: JSON.stringify({ servers: overview, count: overview.length }, null, 2) }] };
2544
+
2545
+ const liveRequested = params.live === true || Boolean(params.alias);
2546
+ if (!liveRequested) {
2547
+ const payload = { mode: "registry", count: registry.length, generatedAt: new Date().toISOString(), servers: registry };
2548
+ return {
2549
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
2550
+ structuredContent: payload
2551
+ };
2552
+ }
2553
+
2554
+ const aliases = await groups.resolveAliases(params.alias || 'all');
2555
+ const registryByAlias = new Map(registry.map(item => [item.alias, item]));
2556
+ const liveServers = await Promise.all(aliases.map(async (alias) => {
2557
+ const base = registryByAlias.get(alias) || { alias };
2558
+ try {
2559
+ const topology = await infraTopology.get(alias, {
2560
+ force: params.force === true,
2561
+ timeoutMs: params.timeoutMs || 25000
2562
+ });
2563
+ return { ok: true, ...base, topology };
2564
+ } catch (error) {
2565
+ return { ok: false, ...base, error: error.message };
2566
+ }
2567
+ }));
2568
+
2569
+ let payload = { mode: "live", count: liveServers.length, generatedAt: new Date().toISOString(), servers: liveServers };
2570
+ if (params.compact !== false) {
2571
+ payload = utils.compactResult(payload, { maxString: 1200, maxArray: 100, dropKeys: ['raw'] });
2572
+ }
2573
+ return {
2574
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
2575
+ structuredContent: payload
2576
+ };
1861
2577
  } catch (e) {
1862
2578
  const errorPayload = { toolName: "infra_overview", errorCode: "OVERVIEW_ERROR", errorMessage: e.message };
1863
2579
  return { content: [{ type: "text", text: JSON.stringify(errorPayload, null, 2) }], isError: true };
@@ -1890,11 +2606,14 @@ server.registerTool(
1890
2606
  })
1891
2607
  },
1892
2608
  async (params) => {
2609
+ try { await guardAliasWritable(params.alias, 'tmux_create'); }
2610
+ catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "tmux_create", errorMessage: e.message }, null, 2) }], isError: true }; }
1893
2611
  const tmuxCheck = await checkTmux(params.alias);
1894
2612
  if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1895
2613
  const sessionName = params.name || `tmux-${Date.now()}`;
1896
- const startCmd = params.start_cmd ? `tmux send-keys -t ${sessionName} '${params.start_cmd}' Enter` : '';
1897
- const cmd = `tmux new-session -d -s ${sessionName} ${startCmd.length > 0 ? '&& ' + startCmd : ''}`;
2614
+ const qSession = utils.escapeShellArg(sessionName);
2615
+ const startCmd = params.start_cmd ? `tmux send-keys -t ${qSession} -- ${utils.escapeShellArg(params.start_cmd)} Enter` : '';
2616
+ const cmd = `tmux new-session -d -s ${qSession}${startCmd ? ' && ' + startCmd : ''}`;
1898
2617
  const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1899
2618
  history.logTask(job);
1900
2619
  ssh.executeCommand(job.id);
@@ -1916,9 +2635,11 @@ server.registerTool(
1916
2635
  })
1917
2636
  },
1918
2637
  async (params) => {
2638
+ try { await guardAliasWritable(params.alias, 'tmux_exec'); }
2639
+ catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "tmux_exec", errorMessage: e.message }, null, 2) }], isError: true }; }
1919
2640
  const tmuxCheck = await checkTmux(params.alias);
1920
2641
  if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1921
- const cmd = `tmux send-keys -t ${params.session} '${params.cmd}' Enter`;
2642
+ const cmd = `tmux send-keys -t ${utils.escapeShellArg(params.session)} -- ${utils.escapeShellArg(params.cmd)} Enter`;
1922
2643
  const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1923
2644
  history.logTask(job);
1924
2645
  ssh.executeCommand(job.id);
@@ -1940,7 +2661,7 @@ server.registerTool(
1940
2661
  async (params) => {
1941
2662
  const tmuxCheck = await checkTmux(params.alias);
1942
2663
  if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1943
- const cmd = `tmux capture-pane -t ${params.session} -p -S -200`;
2664
+ const cmd = `tmux capture-pane -t ${utils.escapeShellArg(params.session)} -p -S -200`;
1944
2665
  const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1945
2666
  history.logTask(job);
1946
2667
  ssh.executeCommand(job.id);
@@ -1983,9 +2704,12 @@ server.registerTool(
1983
2704
  })
1984
2705
  },
1985
2706
  async (params) => {
2707
+ try { await guardAliasWritable(params.alias, 'tmux_kill'); }
2708
+ catch (e) { return { content: [{ type: "text", text: JSON.stringify({ toolName: "tmux_kill", errorMessage: e.message }, null, 2) }], isError: true }; }
1986
2709
  const tmuxCheck = await checkTmux(params.alias);
1987
2710
  if (!tmuxCheck.installed) return { content: [{ type: "text", text: tmuxCheck.message }] };
1988
- const cmd = `tmux kill-session -t ${params.session} 2>/dev/null && echo 'Session ${params.session} tuée.' || echo 'Session introuvable.'`;
2711
+ const qSession = utils.escapeShellArg(params.session);
2712
+ const cmd = `tmux kill-session -t ${qSession} 2>/dev/null && echo ${utils.escapeShellArg(`Session ${params.session} tuée.`)} || echo 'Session introuvable.'`;
1989
2713
  const job = queue.addJob({ type: 'ssh', alias: params.alias, cmd, streaming: false, status: 'pending' });
1990
2714
  history.logTask(job);
1991
2715
  ssh.executeCommand(job.id);
@@ -2014,6 +2738,8 @@ server.registerTool(
2014
2738
  },
2015
2739
  async (params) => {
2016
2740
  try {
2741
+ guardWritable('tunnel_create');
2742
+ if (params.source) await guardAliasWritable(params.source, 'tunnel_create');
2017
2743
  const result = await tunnels.create(params, servers);
2018
2744
  return { content: [{ type: "text", text: result }] };
2019
2745
  } catch (e) {
@@ -2046,6 +2772,9 @@ server.registerTool(
2046
2772
  },
2047
2773
  async (params) => {
2048
2774
  try {
2775
+ guardWritable('tunnel_close');
2776
+ const info = await tunnels.get(params.name);
2777
+ if (info?.source) await guardAliasWritable(info.source, 'tunnel_close');
2049
2778
  const result = await tunnels.close(params.name, servers);
2050
2779
  return { content: [{ type: "text", text: result }] };
2051
2780
  } catch (e) {
@@ -2064,6 +2793,7 @@ server.registerTool(
2064
2793
  })
2065
2794
  },
2066
2795
  async (params) => {
2796
+ guardWritable('tunnel_allowlist_add');
2067
2797
  const list = await tunnels.allowlistAdd(params.port);
2068
2798
  return { content: [{ type: "text", text: `Port ${params.port} ajouté à l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
2069
2799
  }
@@ -2079,6 +2809,7 @@ server.registerTool(
2079
2809
  })
2080
2810
  },
2081
2811
  async (params) => {
2812
+ guardWritable('tunnel_allowlist_remove');
2082
2813
  const list = await tunnels.allowlistRemove(params.port);
2083
2814
  return { content: [{ type: "text", text: `Port ${params.port} retiré de l'allowlist.\nPorts autorisés: ${list.join(', ')}` }] };
2084
2815
  }