@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/servers.js CHANGED
@@ -1,49 +1,35 @@
1
- import fs from 'fs/promises';
2
1
  import path from 'path';
3
2
  import config from './config.js';
3
+ import jsonStore from './atomicJsonStore.js';
4
+
4
5
  const SERVERS_FILE_PATH = path.join(config.dataDir, 'servers.json');
6
+ const EMPTY = {};
5
7
 
6
8
  async function readServers() {
7
- try {
8
- await fs.access(SERVERS_FILE_PATH);
9
- const data = await fs.readFile(SERVERS_FILE_PATH, 'utf-8');
10
- return JSON.parse(data);
11
- } catch (error) {
12
- // Si le fichier n'existe pas, on retourne un objet vide
13
- return {};
14
- }
9
+ return jsonStore.readJson(SERVERS_FILE_PATH, EMPTY);
15
10
  }
16
11
 
17
- async function writeServers(servers) {
18
- await fs.writeFile(SERVERS_FILE_PATH, JSON.stringify(servers, null, 2));
19
- }
20
-
21
- async function addServer(alias, config) {
22
- const servers = await readServers();
23
- if (servers[alias]) {
24
- // L'alias existe, on le met à jour
25
- servers[alias] = { ...servers[alias], ...config };
26
- await writeServers(servers);
27
- return { success: true, message: `Serveur '${alias}' mis à jour avec succès.` };
28
- }
29
- // L'alias n'existe pas, on le crée
30
- servers[alias] = config;
31
- await writeServers(servers);
32
- return { success: true, message: `Serveur '${alias}' ajouté avec succès.` };
12
+ async function addServer(alias, serverConfig) {
13
+ let existed = false;
14
+ await jsonStore.updateJson(SERVERS_FILE_PATH, EMPTY, (servers) => {
15
+ existed = !!servers[alias];
16
+ servers[alias] = existed ? { ...servers[alias], ...serverConfig } : serverConfig;
17
+ return servers;
18
+ });
19
+ return { success: true, message: `Serveur '${alias}' ${existed ? 'mis à jour' : 'ajouté'} avec succès.` };
33
20
  }
34
21
 
35
22
  async function removeServer(alias) {
36
- const servers = await readServers();
37
- if (!servers[alias]) {
38
- throw new Error(`L'alias '${alias}' n'existe pas.`);
39
- }
40
- delete servers[alias];
41
- await writeServers(servers);
23
+ await jsonStore.updateJson(SERVERS_FILE_PATH, EMPTY, (servers) => {
24
+ if (!servers[alias]) throw new Error(`L'alias '${alias}' n'existe pas.`);
25
+ delete servers[alias];
26
+ return servers;
27
+ });
42
28
  return { success: true, message: `Serveur '${alias}' supprimé.` };
43
29
  }
44
30
 
45
31
  async function listServers() {
46
- return await readServers();
32
+ return readServers();
47
33
  }
48
34
 
49
35
  async function getServer(alias) {
@@ -55,4 +41,15 @@ async function getServer(alias) {
55
41
  return serverConfig;
56
42
  }
57
43
 
58
- export default { addServer, removeServer, listServers, getServer };
44
+ /** true si l'alias est marqué readonly:true dans servers.json */
45
+ async function isAliasReadOnly(alias) {
46
+ if (!alias) return false;
47
+ try {
48
+ const sc = await getServer(alias);
49
+ return sc.readonly === true || sc.readOnly === true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ export default { addServer, removeServer, listServers, getServer, isAliasReadOnly };
package/sftp.js CHANGED
@@ -7,6 +7,7 @@ import serverManager from './servers.js';
7
7
  import sourceAdapter from './sourceAdapter.js';
8
8
  import fileOps from './fileOps.js';
9
9
  import micromatch from 'micromatch';
10
+ import utils from './utils.js';
10
11
 
11
12
  // Fonction utilitaire pour créer un dossier parent si nécessaire
12
13
  async function ensureLocalDir(filePath) {
@@ -72,15 +73,94 @@ async function expandFileList(pattern, basePath = '') {
72
73
  // Si c'est un pattern glob
73
74
  if (pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) {
74
75
  const fullPattern = path.resolve(normalizedBasePath, pattern);
76
+ sourceAdapter.assertLocalPathAllowed(fullPattern);
75
77
  const files = await glob(fullPattern, { nodir: false });
78
+ for (const f of files) sourceAdapter.assertLocalPathAllowed(f);
76
79
  return files;
77
80
  }
78
81
 
79
82
  // Sinon c'est un fichier/dossier simple
80
83
  const fullPath = path.resolve(normalizedBasePath, pattern);
84
+ sourceAdapter.assertLocalPathAllowed(fullPath);
81
85
  return [fullPath];
82
86
  }
83
87
 
88
+ /**
89
+ * Copie une entrée remote -> remote via le pool SFTP partagé.
90
+ *
91
+ * - fichier : respecte force:false et append le basename si la cible est un dossier ;
92
+ * - dossier : copie récursivement les fichiers sous la racine cible ; force:false
93
+ * refuse une cible déjà existante afin d'éviter les merges/écrasements implicites.
94
+ *
95
+ * Note : les symlinks inclus dans un arbre sont volontairement ignorés par
96
+ * listFilesRecursive pour l'instant. La future stratégie direct/rsync devra les
97
+ * préserver ; le résultat expose un warning pour ne pas promettre une copie bit-à-bit.
98
+ */
99
+ async function transferServerToServerEntry(file, job, force) {
100
+ const srcAlias = file.source_alias || job.source_alias;
101
+ const srcPath = file.source_path || file.local || file.remote;
102
+ const tgtAlias = job.alias;
103
+ const tgtPath = file.remote;
104
+
105
+ if (!srcAlias) throw new Error("source_alias requis pour server_to_server.");
106
+ if (!srcPath) throw new Error("Chemin source requis (local/source_path) pour server_to_server.");
107
+ if (!tgtPath) throw new Error("Chemin destination remote requis pour server_to_server.");
108
+
109
+ const srcKind = await sourceAdapter.exists({ type: 'remote', alias: srcAlias, path: srcPath });
110
+ if (!srcKind) throw new Error(`Source introuvable: ${srcAlias}:${srcPath}`);
111
+
112
+ if (srcKind === 'd') {
113
+ const dstKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: tgtPath });
114
+ if (dstKind && dstKind !== 'd') {
115
+ throw new Error(`La destination ${tgtAlias}:${tgtPath} existe et n'est pas un dossier.`);
116
+ }
117
+ if (dstKind && !force) {
118
+ throw new Error(
119
+ `Le dossier destination ${tgtAlias}:${tgtPath} existe déjà. ` +
120
+ `Utilisez force:true pour fusionner/écraser explicitement.`
121
+ );
122
+ }
123
+
124
+ const rels = await sourceAdapter.listFilesRecursive(
125
+ { type: 'remote', alias: srcAlias, path: srcPath },
126
+ { recursive: true, ignorePatterns: [] }
127
+ );
128
+ let success = 0;
129
+ const failures = [];
130
+ for (const rel of rels) {
131
+ const from = path.posix.join(srcPath, rel);
132
+ const to = path.posix.join(tgtPath, rel);
133
+ try {
134
+ const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: from });
135
+ await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: to }, buf.content, { createDirs: true });
136
+ success++;
137
+ } catch (e) {
138
+ failures.push({ file: from, target: to, error: e.message });
139
+ }
140
+ }
141
+ return {
142
+ total: rels.length, success, failures,
143
+ warning: 'Copie dossier SFTP: symlinks et dossiers vides non préservés; utiliser une future stratégie rsync/tar pour une réplication bit-à-bit.'
144
+ };
145
+ }
146
+
147
+ let finalTarget = tgtPath;
148
+ const dstKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: tgtPath });
149
+ if (dstKind === 'd') {
150
+ finalTarget = path.posix.join(tgtPath, path.posix.basename(srcPath));
151
+ const nestedKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: finalTarget });
152
+ if (nestedKind && !force) {
153
+ throw new Error(`Le fichier distant ${tgtAlias}:${finalTarget} existe déjà. Utilisez force:true.`);
154
+ }
155
+ } else if (dstKind && !force) {
156
+ throw new Error(`Le fichier distant ${tgtAlias}:${tgtPath} existe déjà. Utilisez force:true.`);
157
+ }
158
+
159
+ const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
160
+ await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: finalTarget }, buf.content, { createDirs: true });
161
+ return { total: 1, success: 1, failures: [] };
162
+ }
163
+
84
164
  // Fonction principale de transfert avec support multi-fichiers
85
165
  async function executeTransfer(jobId) {
86
166
  const job = queue.getJob(jobId);
@@ -88,32 +168,8 @@ async function executeTransfer(jobId) {
88
168
 
89
169
  let sftp = null;
90
170
  try {
91
- const serverConfig = await serverManager.getServer(job.alias);
92
171
  queue.updateJobStatus(jobId, 'running');
93
172
 
94
- sftp = new SftpClient();
95
-
96
- // Configuration de la connexion
97
- const config = {
98
- host: serverConfig.host,
99
- port: 22,
100
- username: serverConfig.user,
101
- readyTimeout: 20000,
102
- retries: 3,
103
- retry_factor: 2,
104
- retry_minTimeout: 2000
105
- };
106
-
107
- if (serverConfig.keyPath) {
108
- config.privateKey = await fs.readFile(serverConfig.keyPath);
109
- } else if (serverConfig.password) {
110
- config.password = serverConfig.password;
111
- } else {
112
- throw new Error(`Aucune méthode d'authentification pour '${job.alias}'.`);
113
- }
114
-
115
- await sftp.connect(config);
116
-
117
173
  // Déterminer si on traite plusieurs fichiers
118
174
  const files = job.files || [{ local: job.local, remote: job.remote }];
119
175
  const isMultiple = Array.isArray(job.files) && job.files.length > 1;
@@ -123,45 +179,90 @@ async function executeTransfer(jobId) {
123
179
  let failedFiles = [];
124
180
  let totalFiles = 0;
125
181
 
126
- for (let i = 0; i < files.length; i++) {
127
- const file = files[i];
128
- const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
129
-
130
- try {
131
- if (job.direction === 'upload') {
132
- const localFiles = await expandFileList(file.local);
133
- totalFiles += localFiles.length;
134
- for (const localFile of localFiles) {
135
- queue.log('info', `Transfert${progress}: ${localFile}`);
136
- await handleUpload(sftp, localFile, file.remote, force);
137
- successCount++;
182
+ // server_to_server : 100% pool via sourceAdapter (fichiers + dossiers)
183
+ if (job.direction === 'server_to_server') {
184
+ const warnings = [];
185
+ for (let i = 0; i < files.length; i++) {
186
+ const file = files[i];
187
+ const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
188
+ const srcAlias = file.source_alias || job.source_alias;
189
+ const srcPath = file.source_path || file.local || file.remote;
190
+ try {
191
+ queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${job.alias}:${file.remote}`);
192
+ const result = await transferServerToServerEntry(file, job, force);
193
+ totalFiles += result.total;
194
+ successCount += result.success;
195
+ if (result.failures?.length) failedFiles.push(...result.failures);
196
+ if (result.warning && !warnings.includes(result.warning)) warnings.push(result.warning);
197
+ } catch (err) {
198
+ totalFiles += 1; // l'item lui-même a été tenté, même si son contenu n'a pas pu être énuméré
199
+ queue.log('error', `Échec transfert ${srcPath || file.remote}: ${err.message}`);
200
+ failedFiles.push({ file: srcPath || file.remote, error: err.message });
201
+ }
202
+ }
203
+ job.transferWarnings = warnings;
204
+ } else {
205
+ // upload/download : SftpClient (dirs/glob) avec port configurable
206
+ const serverConfig = await serverManager.getServer(job.alias);
207
+ sftp = new SftpClient();
208
+ const sftpConfig = {
209
+ host: serverConfig.host,
210
+ port: utils.resolveSshPort(serverConfig),
211
+ username: serverConfig.user,
212
+ readyTimeout: 20000,
213
+ retries: 3,
214
+ retry_factor: 2,
215
+ retry_minTimeout: 2000
216
+ };
217
+ if (serverConfig.keyPath) {
218
+ sftpConfig.privateKey = await fs.readFile(serverConfig.keyPath);
219
+ } else if (serverConfig.password) {
220
+ sftpConfig.password = serverConfig.password;
221
+ } else {
222
+ throw new Error(`Aucune méthode d'authentification pour '${job.alias}'.`);
223
+ }
224
+ await sftp.connect(sftpConfig);
225
+
226
+ for (let i = 0; i < files.length; i++) {
227
+ const file = files[i];
228
+ const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
229
+
230
+ try {
231
+ if (job.direction === 'upload') {
232
+ const localFiles = await expandFileList(file.local);
233
+ totalFiles += localFiles.length;
234
+ for (const localFile of localFiles) {
235
+ queue.log('info', `Transfert${progress}: ${localFile}`);
236
+ // Fichier simple (pas dir) → pool sourceAdapter quand possible
237
+ let isDir = false;
238
+ try {
239
+ const st = await fs.stat(localFile);
240
+ isDir = st.isDirectory();
241
+ } catch { /* handled in handleUpload */ }
242
+ if (!isDir && !hasGlobPattern(file.remote || '')) {
243
+ const content = await fs.readFile(localFile);
244
+ let remotePath = file.remote;
245
+ // Si remote est un dossier existant, append basename — handleUpload fait ça ;
246
+ // pour pool on délègue à handleUpload si exists dir, sinon writeFile
247
+ await handleUpload(sftp, localFile, file.remote, force);
248
+ } else {
249
+ await handleUpload(sftp, localFile, file.remote, force);
250
+ }
251
+ successCount++;
252
+ }
253
+ } else if (job.direction === 'download') {
254
+ const downloadedCount = await handleDownload(sftp, file.remote, file.local, force);
255
+ totalFiles += downloadedCount;
256
+ successCount += downloadedCount;
138
257
  }
139
- } else if (job.direction === 'download') {
140
- const downloadedCount = await handleDownload(sftp, file.remote, file.local, force);
141
- totalFiles += downloadedCount;
142
- successCount += downloadedCount;
143
- } else if (job.direction === 'server_to_server') {
144
- // Transfert direct entre deux serveurs distants (ou local↔remote inversé)
145
- const srcAlias = file.source_alias || job.source_alias;
146
- const srcPath = file.source_path || file.local || file.remote;
147
- const tgtAlias = job.alias;
148
- const tgtPath = file.remote;
149
-
150
- queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${tgtAlias}:${tgtPath}`);
151
- const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
152
- await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: tgtPath }, buf.content, { createDirs: true });
153
- successCount++;
154
- totalFiles++;
258
+ } catch (err) {
259
+ queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
260
+ failedFiles.push({ file: file.local || file.remote, error: err.message });
155
261
  }
156
- } catch (err) {
157
- queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
158
- failedFiles.push({ file: file.local || file.remote, error: err.message });
159
262
  }
160
263
  }
161
264
 
162
- await sftp.end();
163
-
164
- // Génération du rapport
265
+ // Génération du rapport (end() uniquement dans finally pour éviter double close)
165
266
  let status = successCount === totalFiles ? 'completed' : 'partial';
166
267
  let output = `Transfert ${job.direction}: ${successCount}/${totalFiles} fichiers réussis`;
167
268
 
@@ -170,7 +271,10 @@ async function executeTransfer(jobId) {
170
271
  if (successCount === 0) status = 'failed';
171
272
  }
172
273
 
173
- queue.updateJobStatus(jobId, status, { output, failedFiles });
274
+ if (job.transferWarnings?.length) {
275
+ output += `\nAvertissements: ${job.transferWarnings.join(' | ')}`;
276
+ }
277
+ queue.updateJobStatus(jobId, status, { output, failedFiles, warnings: job.transferWarnings || [] });
174
278
 
175
279
  } catch (err) {
176
280
  queue.updateJobStatus(jobId, 'failed', { error: err.message });
@@ -187,6 +291,7 @@ async function executeTransfer(jobId) {
187
291
 
188
292
  // Gestion spécifique de l'upload
189
293
  async function handleUpload(sftp, localPath, remotePath, force = false) {
294
+ sourceAdapter.assertLocalPathAllowed(localPath);
190
295
  let localStats;
191
296
  try {
192
297
  localStats = await fs.stat(localPath);
@@ -242,6 +347,7 @@ async function handleUpload(sftp, localPath, remotePath, force = false) {
242
347
 
243
348
  // Gestion spécifique du download
244
349
  async function handleDownload(sftp, remotePath, localPath, force = false) {
350
+ sourceAdapter.assertLocalPathAllowed(localPath);
245
351
  if (hasGlobPattern(remotePath)) {
246
352
  const parentDir = path.dirname(remotePath);
247
353
  const pattern = path.basename(remotePath);
@@ -257,8 +363,9 @@ async function handleDownload(sftp, remotePath, localPath, force = false) {
257
363
  await fs.mkdir(localPath, { recursive: true });
258
364
 
259
365
  for (const fileName of matchingFiles) {
260
- const remoteFile = path.join(parentDir, fileName);
366
+ const remoteFile = path.posix.join(parentDir, fileName);
261
367
  const localFile = path.join(localPath, fileName);
368
+ sourceAdapter.assertLocalPathAllowed(localFile);
262
369
 
263
370
  const localFileExists = await fs.access(localFile).then(() => true).catch(() => false);
264
371
  if (localFileExists && !force) {
@@ -319,6 +426,7 @@ async function handleDownload(sftp, remotePath, localPath, force = false) {
319
426
  }
320
427
  }
321
428
 
429
+ sourceAdapter.assertLocalPathAllowed(finalLocalPath);
322
430
  const localDir = path.dirname(finalLocalPath);
323
431
  await fs.mkdir(localDir, { recursive: true });
324
432
  await sftp.get(remotePath, finalLocalPath);
package/shellSessions.js CHANGED
@@ -2,6 +2,8 @@ import { Client } from 'ssh2';
2
2
  import fs from 'fs/promises';
3
3
  import crypto from 'crypto';
4
4
  import serverManager from './servers.js';
5
+ import policies from './policies.js';
6
+ import utils from './utils.js';
5
7
 
6
8
  /**
7
9
  * shellSessions — Sessions shell PTY PERSISTANTES sur serveurs distants.
@@ -34,7 +36,7 @@ class ShellSessionManager {
34
36
  const sc = await serverManager.getServer(alias);
35
37
  const config = {
36
38
  host: sc.host,
37
- port: sc.port || 22,
39
+ port: utils.resolveSshPort(sc),
38
40
  username: sc.user,
39
41
  readyTimeout: 20000,
40
42
  keepaliveInterval: 30000,
@@ -168,8 +170,17 @@ class ShellSessionManager {
168
170
 
169
171
  /**
170
172
  * Exécute une commande dans une session existante (état persistant).
173
+ * options = { timeoutSec, skip_policy }
171
174
  */
172
- async execInSession(id, command, timeoutSec) {
175
+ async execInSession(id, command, timeoutSec, options = {}) {
176
+ // Rétrocompat : 3e arg peut être un number (timeout) ou un objet options
177
+ let opts = options;
178
+ let timeout = timeoutSec;
179
+ if (typeof timeoutSec === 'object' && timeoutSec !== null) {
180
+ opts = timeoutSec;
181
+ timeout = opts.timeoutSec ?? opts.timeout;
182
+ }
183
+
173
184
  const session = this.sessions.get(id);
174
185
  if (!session) throw new Error(`Session '${id}' introuvable. Utilisez shell_list pour voir les sessions actives.`);
175
186
  if (session.closed) {
@@ -178,16 +189,33 @@ class ShellSessionManager {
178
189
  }
179
190
  if (session.busy) throw new Error(`Session '${id}' occupée : une seule commande à la fois par session.`);
180
191
 
192
+ if (!opts.skip_policy) {
193
+ const policyCheck = await policies.check(command);
194
+ if (policyCheck.blocked) {
195
+ throw new Error(
196
+ `Commande bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). ` +
197
+ `Utilisez skip_policy:true pour forcer.`
198
+ );
199
+ }
200
+ }
201
+
181
202
  session.busy = true;
182
203
  session.lastUsed = Date.now();
183
204
  session.commandCount++;
184
205
  try {
185
- return await this._exec(session, command, timeoutSec ?? session.cmdTimeout);
206
+ return await this._exec(session, command, timeout ?? session.cmdTimeout);
186
207
  } finally {
187
208
  session.busy = false;
188
209
  }
189
210
  }
190
211
 
212
+ /** Retourne les métadonnées minimales d'une session sans exposer la connexion. */
213
+ getSessionInfo(id) {
214
+ const s = this.sessions.get(id);
215
+ if (!s) return null;
216
+ return { id: s.id, alias: s.alias, closed: s.closed, busy: s.busy };
217
+ }
218
+
191
219
  /**
192
220
  * Liste les sessions actives avec leur état.
193
221
  */
@@ -4,6 +4,7 @@ import crypto from 'crypto';
4
4
  import { createTwoFilesPatch } from 'diff';
5
5
  import config from './config.js';
6
6
  import sourceAdapter from './sourceAdapter.js';
7
+ import jsonStore from './atomicJsonStore.js';
7
8
 
8
9
  /**
9
10
  * snapshotManager — Versioning d'infrastructure (style gencodedoc).
@@ -37,17 +38,12 @@ async function ensureDirs() {
37
38
  }
38
39
 
39
40
  async function readIndex() {
40
- try {
41
- const data = await fs.readFile(INDEX_FILE, 'utf8');
42
- return JSON.parse(data);
43
- } catch {
44
- return { snapshots: [] };
45
- }
41
+ return jsonStore.readJson(INDEX_FILE, { snapshots: [] });
46
42
  }
47
43
 
48
44
  async function writeIndex(index) {
49
45
  await ensureDirs();
50
- await fs.writeFile(INDEX_FILE, JSON.stringify(index, null, 2));
46
+ await jsonStore.writeJsonAtomic(INDEX_FILE, index);
51
47
  }
52
48
 
53
49
  function objectPath(hash) {
package/sourceAdapter.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import fs from 'fs/promises';
2
+ import fsSync from 'fs';
2
3
  import path from 'path';
3
4
  import crypto from 'crypto';
4
5
  import micromatch from 'micromatch';
5
6
  import serverManager from './servers.js';
6
7
  import sshPool from './sshPool.js';
7
8
  import { z } from 'zod';
9
+ import config from './config.js';
8
10
 
9
11
  /**
10
12
  * sourceAdapter — Abstraction unifiée local/remote.
@@ -148,6 +150,52 @@ function describe(source) {
148
150
  };
149
151
  }
150
152
 
153
+ /**
154
+ * Politique transversale des chemins locaux.
155
+ *
156
+ * Historiquement MCP_ALLOWED_ROOTS n'était vérifié que dans fileOps.js, ce qui
157
+ * laissait snapshots/transferts contourner la restriction. La validation vit
158
+ * désormais au niveau sourceAdapter, porte d'entrée commune des accès locaux.
159
+ *
160
+ * La vérification combine :
161
+ * - contrôle lexical du chemin absolu ;
162
+ * - realpath du chemin (ou du parent existant le plus proche) pour empêcher
163
+ * un symlink situé dans une racine autorisée de sortir de cette racine.
164
+ */
165
+ function assertLocalPathAllowed(sourcePath) {
166
+ if (!config.allowedRoots || config.allowedRoots.length === 0) return;
167
+ if (!sourcePath || typeof sourcePath !== 'string') {
168
+ throw new Error("Chemin local invalide.");
169
+ }
170
+
171
+ const requested = path.resolve(sourcePath);
172
+
173
+ function realExistingAnchor(p) {
174
+ let cur = p;
175
+ while (!fsSync.existsSync(cur)) {
176
+ const parent = path.dirname(cur);
177
+ if (parent === cur) break;
178
+ cur = parent;
179
+ }
180
+ try { return fsSync.realpathSync.native(cur); } catch { return path.resolve(cur); }
181
+ }
182
+
183
+ const requestedAnchorReal = realExistingAnchor(requested);
184
+ const allowed = config.allowedRoots.some((root) => {
185
+ const rootAbs = path.resolve(root);
186
+ const lexical = requested === rootAbs || requested.startsWith(rootAbs + path.sep);
187
+ if (!lexical) return false;
188
+ const rootReal = realExistingAnchor(rootAbs);
189
+ return requestedAnchorReal === rootReal || requestedAnchorReal.startsWith(rootReal + path.sep);
190
+ });
191
+
192
+ if (!allowed) {
193
+ throw new Error(
194
+ `Accès local refusé : '${requested}' est hors MCP_ALLOWED_ROOTS (${config.allowedRoots.join(', ')}).`
195
+ );
196
+ }
197
+ }
198
+
151
199
  // Valide la structure d'une source
152
200
  function validateSource(source) {
153
201
  if (!source || typeof source !== 'object') {
@@ -162,11 +210,16 @@ function validateSource(source) {
162
210
  if (source.type === 'remote' && !source.alias) {
163
211
  throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
164
212
  }
213
+ if (source.type === 'local') {
214
+ assertLocalPathAllowed(source.path);
215
+ }
165
216
  }
166
217
 
167
- export { sourceSchema };
218
+ export { sourceSchema, assertLocalPathAllowed };
168
219
 
169
220
  export default {
221
+ assertLocalPathAllowed,
222
+
170
223
  /**
171
224
  * Décrit une source : { server, type, path, label }.
172
225
  * server = 'localhost' ou alias serveur. Utile pour indiquer clairement
package/ssh.js CHANGED
@@ -5,6 +5,7 @@ import serverManager from './servers.js';
5
5
  import sshPool from './sshPool.js';
6
6
  import config from './config.js';
7
7
  import policies from './policies.js';
8
+ import utils from './utils.js';
8
9
 
9
10
  const STREAMING_COMMANDS = [
10
11
  /^pm2\s+logs?\b/i,
@@ -319,7 +320,7 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
319
320
 
320
321
  const connectConfig = {
321
322
  host: serverConfig.host,
322
- port: 22,
323
+ port: utils.resolveSshPort(serverConfig),
323
324
  username: serverConfig.user,
324
325
  readyTimeout: 20000
325
326
  };
@@ -366,10 +367,23 @@ async function executeCommandSequence(jobId) {
366
367
  const stepJob = {
367
368
  ...job,
368
369
  cmd: typeof cmd === 'string' ? cmd : cmd.command,
369
- timeout: cmd.timeout || job.timeout,
370
- continueOnError: cmd.continueOnError || job.continueOnError
370
+ timeout: (typeof cmd === 'object' && cmd.timeout != null) ? cmd.timeout : job.timeout,
371
+ continueOnError: (typeof cmd === 'object' && cmd.continueOnError != null)
372
+ ? cmd.continueOnError
373
+ : job.continueOnError
371
374
  };
372
375
 
376
+ // Policy par étape (sauf skip_policy)
377
+ if (!job.skip_policy) {
378
+ const policyCheck = await policies.check(stepJob.cmd);
379
+ if (policyCheck.blocked) {
380
+ throw new Error(
381
+ `Étape ${i + 1} bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). ` +
382
+ `Utilisez skip_policy:true pour forcer.`
383
+ );
384
+ }
385
+ }
386
+
373
387
  try {
374
388
  queue.log('info', `Exécution étape ${i + 1}/${job.commands.length}: ${stepJob.cmd}`);
375
389
  // Don't update the main queue for sub-steps with phantom IDs