mcp-ssh-server-tool 2.1.0 → 2.2.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/README.md CHANGED
@@ -181,12 +181,60 @@ ssh_exec(command)
181
181
  }
182
182
  ```
183
183
 
184
+ ## 命令复核自动应答(auto_confirm_review)
185
+
186
+ ### 背景
187
+
188
+ 堡垒机(JumpServer)若开了**命令过滤 ACL** 且动作是「审批」,命中其命令组正则的命令会被 koko 拦下,
189
+ 在终端弹出交互提示:
190
+
191
+ ```
192
+ 需要复核,是否继续?[Y/N]
193
+ ```
194
+
195
+ `exec` 通道没有 stdin,应答不了这个提示 —— 命令会一直卡住直到超时。
196
+ 本工具的非只读命令走的是 **PTY shell 通道**,所以**有能力把 `y` 写回去**。
197
+
198
+ ### 语义澄清(重要)
199
+
200
+ **写 `y` 只是「提交审批申请」,命令不会立刻执行** —— 仍需审批人在 JumpServer 侧批准。
201
+ 所以本功能**不绕过任何管控**,只是把「人工在终端按 y」这一步自动化了。
202
+
203
+ > ⚠️ 代价:工单的「申请人」会显示为当前堡垒机用户,但实际是工具自动发起的。
204
+ > 审批人无法从工单上区分「本人按的」与「工具自动按的」。启用前请确认这符合你们的管控要求。
205
+
206
+ ### 行为
207
+
208
+ | `auto_confirm_review` | 检测到复核提示时 |
209
+ |---|---|
210
+ | `false`(默认) | **立刻返回** `errorType: "ReviewRequired"`,不再白等到超时;`stdout` 里带上提示原文 |
211
+ | `true` | 自动写 `y` 提交申请,然后把看门狗放宽到 `review_wait`(默认 10 分钟)等审批 |
212
+
213
+ 返回值里新增两个字段,便于调用方判断:
214
+
215
+ - `reviewPrompted` —— 是否检测到复核提示
216
+ - `autoConfirmed` —— 是否已自动应答
217
+
218
+ **审批通过后**,shell 会继续执行原命令并回显结束哨兵 —— 也就是说
219
+ **现有解析逻辑无需改动**,命令正常执行、`exitCode` 正常返回。
220
+ 超时未获批则返回 `errorType: "ReviewPending"`。
221
+
222
+ ### 开关
223
+
224
+ - 每次调用传参:`auto_confirm_review: true`
225
+ - 全局默认开启:环境变量 `SSH_AUTO_CONFIRM_REVIEW=1`
226
+ - 等待时长:`review_wait`(秒)或环境变量 `SSH_REVIEW_WAIT_MS`(毫秒)
227
+
184
228
  ## 已知限制
185
229
 
186
230
  - **命令以 `exit` / `exec` 结尾会终止 shell**:哨兵无法回显,命令会走到超时。此时仍会返回已捕获的输出,但 `exitCode` 为 `null`。
187
231
  - **`exitCode` 取的是最后一条命令的退出码**:`a; b; c` 形式的复合命令,返回的是 `c` 的退出码。
188
232
  - **shell 通道有固定开销**:每次都要走一次 shell 初始化(MOTD 等),比 `exec` 慢约 1~2 秒。
189
233
  - **只读判定是安全网,不是证明**:它做静态字符串分析,绕过方式是存在的。真正的强约束应放在服务端(如堡垒机的命令过滤 ACL)。
234
+ - **复核提示的识别靠关键词匹配**(`需要复核` / `是否继续` / `是否提交申请` / `命令需要审批`)。
235
+ 若目标环境的提示文案不同,需同步调整 `ssh_manager.js` 里的 `REVIEW_PROMPT_RE`;
236
+ 文案不匹配时表现为「没识别到提示,走普通超时」。
237
+ - **自动应答只在 shell 通道生效**:只读命令走 `exec` 通道,本来也不会被拦(除非 ACL 覆盖了只读命令名)。
190
238
 
191
239
  ## 依赖
192
240
 
package/index.js CHANGED
@@ -45,6 +45,18 @@ const tools = [
45
45
  enum: ['force'],
46
46
  description: '留痕控制。不传时由只读白名单自动判定:只读命令走 exec 通道(快、不产生审计记录),其余一律走 shell 通道(产生 JumpServer 可回放会话)。传 force 则强制走 shell 通道留痕。没有「关闭留痕」选项,留痕保证不可被调用方绕过。',
47
47
  },
48
+ auto_confirm_review: {
49
+ type: 'boolean',
50
+ description: '遇到 JumpServer「命令复核」提示(如「需要复核,是否继续?[Y/N]」)时,是否自动应答 y 以提交审批申请。默认 false(此时会立刻返回 ReviewRequired 错误,不白等超时);也可用环境变量 SSH_AUTO_CONFIRM_REVIEW=1 设为全局默认。注意:写 y 只是提交申请,命令仍需审批人批准后才会执行 —— 不绕过任何管控。',
51
+ },
52
+ shell_timeout: {
53
+ type: 'number',
54
+ description: 'shell 通道的命令超时(秒)。默认 30,可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖。',
55
+ },
56
+ review_wait: {
57
+ type: 'number',
58
+ description: 'auto_confirm_review 生效后,等待审批人处理的时长(秒)。默认 600(10 分钟),可用环境变量 SSH_REVIEW_WAIT_MS 覆盖。',
59
+ },
48
60
  },
49
61
  },
50
62
  },
@@ -75,6 +87,18 @@ const tools = [
75
87
  passphrase: { type: 'string', description: '私钥密码' },
76
88
  timeout: { type: 'number', description: '超时时间(秒)', default: 10 },
77
89
  test_command: { type: 'string', description: '测试命令(默认: echo test)', default: 'echo test' },
90
+ auto_confirm_review: {
91
+ type: 'boolean',
92
+ description: '遇到 JumpServer「命令复核」提示时是否自动应答 y 提交审批申请。默认 false;也可用环境变量 SSH_AUTO_CONFIRM_REVIEW=1 设为全局默认。写 y 只是提交申请,命令仍需审批人批准。',
93
+ },
94
+ shell_timeout: {
95
+ type: 'number',
96
+ description: 'shell 通道的命令超时(秒)。默认 30,可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖。',
97
+ },
98
+ review_wait: {
99
+ type: 'number',
100
+ description: 'auto_confirm_review 生效后,等待审批人处理的时长(秒)。默认 600,可用环境变量 SSH_REVIEW_WAIT_MS 覆盖。',
101
+ },
78
102
  },
79
103
  required: ['host', 'username'],
80
104
  },
@@ -147,6 +171,14 @@ async function handleRequest(request) {
147
171
  const hasSession = toolArgs.session_id;
148
172
  const hasAuth = toolArgs.host && toolArgs.username;
149
173
 
174
+ // 统一整理执行选项(含「命令复核」自动应答与超时)
175
+ const execOpts = {
176
+ audit: toolArgs.audit,
177
+ autoConfirmReview: toolArgs.auto_confirm_review,
178
+ shellTimeoutMs: toolArgs.shell_timeout ? toolArgs.shell_timeout * 1000 : undefined,
179
+ reviewWaitMs: toolArgs.review_wait ? toolArgs.review_wait * 1000 : undefined,
180
+ };
181
+
150
182
  if (!hasSession && !hasAuth) {
151
183
  send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify({ success: false, error: 'Either session_id or host/username required' }) }] } });
152
184
  return;
@@ -171,9 +203,9 @@ async function handleRequest(request) {
171
203
 
172
204
  let result;
173
205
  if (toolArgs.commands) {
174
- result = await sshManager.execCommands(conn.sessionId, toolArgs.commands, { audit: toolArgs.audit });
206
+ result = await sshManager.execCommands(conn.sessionId, toolArgs.commands, execOpts);
175
207
  } else if (toolArgs.command) {
176
- result = await sshManager.execCommand(conn.sessionId, toolArgs.command, { audit: toolArgs.audit });
208
+ result = await sshManager.execCommand(conn.sessionId, toolArgs.command, execOpts);
177
209
  }
178
210
 
179
211
  sshManager.disconnect(conn.sessionId);
@@ -184,9 +216,9 @@ async function handleRequest(request) {
184
216
  if (hasSession) {
185
217
  let result;
186
218
  if (toolArgs.commands) {
187
- result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands, { audit: toolArgs.audit });
219
+ result = await sshManager.execCommands(toolArgs.session_id, toolArgs.commands, execOpts);
188
220
  } else if (toolArgs.command) {
189
- result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command, { audit: toolArgs.audit });
221
+ result = await sshManager.execCommand(toolArgs.session_id, toolArgs.command, execOpts);
190
222
  }
191
223
  send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
192
224
  return;
@@ -216,7 +248,11 @@ async function handleRequest(request) {
216
248
  return;
217
249
  }
218
250
 
219
- const execResult = await sshManager.execCommand(conn.sessionId, toolArgs.test_command || 'echo test');
251
+ const execResult = await sshManager.execCommand(conn.sessionId, toolArgs.test_command || 'echo test', {
252
+ autoConfirmReview: toolArgs.auto_confirm_review,
253
+ shellTimeoutMs: toolArgs.shell_timeout ? toolArgs.shell_timeout * 1000 : undefined,
254
+ reviewWaitMs: toolArgs.review_wait ? toolArgs.review_wait * 1000 : undefined,
255
+ });
220
256
  sshManager.disconnect(conn.sessionId);
221
257
 
222
258
  send({
package/package.json CHANGED
@@ -1,33 +1,33 @@
1
- {
2
- "name": "mcp-ssh-server-tool",
3
- "version": "2.1.0",
4
- "description": "MCP Server for SSH connections and remote command execution",
5
- "main": "index.js",
6
- "type": "module",
7
- "bin": {
8
- "mcp-ssh-server-tool": "bin/cli.js"
9
- },
10
- "scripts": {
11
- "start": "node index.js"
12
- },
13
- "keywords": [
14
- "mcp",
15
- "ssh",
16
- "server",
17
- "mcp-server",
18
- "remote-execution"
19
- ],
20
- "author": "",
21
- "license": "MIT",
22
- "repository": {
23
- "type": "git",
24
- "url": ""
25
- },
26
- "dependencies": {
27
- "ssh2": "^1.15.0"
28
- },
29
- "engines": {
30
- "node": ">=18.0.0"
31
- },
32
- "preferGlobal": true
33
- }
1
+ {
2
+ "name": "mcp-ssh-server-tool",
3
+ "version": "2.2.0",
4
+ "description": "MCP Server for SSH connections and remote command execution",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "mcp-ssh-server-tool": "bin/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node index.js"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "ssh",
16
+ "server",
17
+ "mcp-server",
18
+ "remote-execution"
19
+ ],
20
+ "author": "",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": ""
25
+ },
26
+ "dependencies": {
27
+ "ssh2": "^1.15.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=18.0.0"
31
+ },
32
+ "preferGlobal": true
33
+ }
package/ssh_manager.js CHANGED
@@ -22,6 +22,30 @@ import { resolveChannel } from './readonly_policy.js';
22
22
  // 可用环境变量 SSH_SHELL_TIMEOUT_MS 覆盖
23
23
  const SHELL_TIMEOUT_MS = Number(process.env.SSH_SHELL_TIMEOUT_MS || 30000);
24
24
 
25
+ // ---------------------------------------------------------------------------
26
+ // JumpServer「命令复核」提示的识别与应答
27
+ // ---------------------------------------------------------------------------
28
+ //
29
+ // 堡垒机开启命令过滤(ACL action=review)后,命中其命令组正则的命令会被 koko 拦下,
30
+ // 在终端里弹出交互提示,形如:
31
+ //
32
+ // 需要复核,是否继续?[Y/N]
33
+ //
34
+ // 本工具走的是 **PTY shell 通道**,所以有能力把这个 y 写回去 —— 这就是「提交审批申请」。
35
+ //
36
+ // ⚠️ 语义澄清(很重要):
37
+ // 写 y 只是「提交申请」,命令**不会立刻执行** —— 仍需审批人在 JumpServer 侧批准。
38
+ // 所以本功能**不绕过任何管控**,只是把「人工在终端按 y」这一步自动化了。
39
+ // 代价:工单的「申请人」会显示为当前堡垒机用户,但实际是工具自动发起的,
40
+ // 审批人无法从工单上区分「本人按的」与「工具自动按的」。
41
+ //
42
+ // 开关与时长:
43
+ // SSH_AUTO_CONFIRM_REVIEW=1 把「自动应答」设为全局默认(每次调用也可用参数覆盖)
44
+ // SSH_REVIEW_WAIT_MS 自动应答后等待审批的时长,默认 10 分钟
45
+ const REVIEW_PROMPT_RE = /需要复核|是否继续|是否提交申请|命令需要审批/;
46
+ const REVIEW_WAIT_MS = Number(process.env.SSH_REVIEW_WAIT_MS || 10 * 60 * 1000);
47
+ const AUTO_CONFIRM_DEFAULT = process.env.SSH_AUTO_CONFIRM_REVIEW === '1';
48
+
25
49
  // 哨兵用的随机串。故意让「发送的命令行」里只出现 ${N} 而非展开值,
26
50
  // 这样 shell 回显的输入行不会与真实标记串冲突。
27
51
  const NONCE = '7f3a2c';
@@ -137,8 +161,17 @@ class SSHConnectionManager {
137
161
 
138
162
  const { channel, reason } = resolveChannel(command, options.audit);
139
163
 
164
+ // 未显式传参时,用环境变量决定的全局默认
165
+ const autoConfirmReview = options.autoConfirmReview === undefined
166
+ ? AUTO_CONFIRM_DEFAULT
167
+ : options.autoConfirmReview === true;
168
+
140
169
  const result = channel === 'shell'
141
- ? await this._execViaShell(session, command)
170
+ ? await this._execViaShell(session, command, {
171
+ autoConfirmReview,
172
+ shellTimeoutMs: options.shellTimeoutMs,
173
+ reviewWaitMs: options.reviewWaitMs,
174
+ })
142
175
  : await this._execViaExec(session, command);
143
176
 
144
177
  return { ...result, channel, channelReason: reason };
@@ -183,7 +216,11 @@ class SSHConnectionManager {
183
216
  // shell 通道:非只读命令,产生可回放的 JumpServer 会话
184
217
  // ---------------------------------------------------------------------------
185
218
 
186
- _execViaShell(session, command) {
219
+ _execViaShell(session, command, opts = {}) {
220
+ const autoConfirm = opts.autoConfirmReview === true;
221
+ const baseTimeoutMs = opts.shellTimeoutMs || SHELL_TIMEOUT_MS;
222
+ const reviewWaitMs = opts.reviewWaitMs || REVIEW_WAIT_MS;
223
+
187
224
  return new Promise((resolve) => {
188
225
  session.client.shell(
189
226
  { term: 'xterm-256color', cols: 200, rows: 50 },
@@ -195,34 +232,74 @@ class SSHConnectionManager {
195
232
 
196
233
  let buf = '';
197
234
  let settled = false;
235
+ let reviewPrompted = false; // 是否检测到「命令复核」提示
236
+ let autoConfirmed = false; // 是否已自动应答 y
237
+ let timer = null;
238
+
239
+ // 取已捕获的输出(哨兵之后的部分)
240
+ const partialOut = () => {
241
+ const k = buf.indexOf(START_MARK);
242
+ return k === -1 ? '' : trimBlank(stripAnsi(buf.slice(k + START_MARK.length)));
243
+ };
198
244
 
199
245
  const finish = (payload) => {
200
246
  if (settled) return;
201
247
  settled = true;
202
248
  clearTimeout(timer);
203
249
  try { stream.end(); } catch { /* ignore */ }
204
- resolve({ ...payload, sessionId: session.sessionId, command });
250
+ resolve({ ...payload, sessionId: session.sessionId, command, reviewPrompted, autoConfirmed });
205
251
  };
206
252
 
207
- const timer = setTimeout(() => {
208
- // 超时也要交出已捕获的输出:命令可能是 exit / exec 这类
209
- // 会直接终止 shell 的,哨兵永远不会出现
210
- const k = buf.indexOf(START_MARK);
211
- const partial = k === -1
212
- ? ''
213
- : trimBlank(stripAnsi(buf.slice(k + START_MARK.length)));
253
+ // 可重置的看门狗:超时也要交出已捕获的输出,
254
+ // 因为命令可能是 exit / exec 这类会直接终止 shell 的,哨兵永远不会出现
255
+ const armTimer = (ms, onTimeout) => {
256
+ clearTimeout(timer);
257
+ timer = setTimeout(onTimeout, ms);
258
+ };
259
+
260
+ armTimer(baseTimeoutMs, () => {
214
261
  finish({
215
262
  success: false,
216
- error: `命令超时(${SHELL_TIMEOUT_MS}ms)。会话已在 JumpServer 留痕`,
263
+ error: `命令超时(${baseTimeoutMs}ms)。会话已在 JumpServer 留痕`,
217
264
  errorType: 'Timeout',
218
- stdout: partial,
265
+ stdout: partialOut(),
219
266
  exitCode: null,
220
267
  });
221
- }, SHELL_TIMEOUT_MS);
268
+ });
222
269
 
223
270
  stream.on('data', (d) => {
224
271
  buf += d.toString();
225
272
 
273
+ // ---- JumpServer「命令复核」拦截 ----
274
+ if (!reviewPrompted && REVIEW_PROMPT_RE.test(stripAnsi(buf))) {
275
+ reviewPrompted = true;
276
+
277
+ if (!autoConfirm) {
278
+ // 不自动应答:立刻给出明确错误,别让调用方白等到超时
279
+ finish({
280
+ success: false,
281
+ error: '命令被 JumpServer「命令复核」拦截:需在终端按 y 提交审批申请(本次未自动应答)',
282
+ errorType: 'ReviewRequired',
283
+ stdout: partialOut(),
284
+ exitCode: null,
285
+ });
286
+ return;
287
+ }
288
+
289
+ // 自动应答:写 y 提交审批申请,并把看门狗放宽到 reviewWaitMs
290
+ try { stream.write('y\n'); } catch { /* ignore */ }
291
+ autoConfirmed = true;
292
+ armTimer(reviewWaitMs, () => {
293
+ finish({
294
+ success: false,
295
+ error: `已提交审批申请,但等待 ${Math.round(reviewWaitMs / 1000)}s 仍未获批。会话已在 JumpServer 留痕`,
296
+ errorType: 'ReviewPending',
297
+ stdout: partialOut(),
298
+ exitCode: null,
299
+ });
300
+ });
301
+ }
302
+
226
303
  const i = buf.indexOf(START_MARK);
227
304
  if (i === -1) return;
228
305
  const j = buf.indexOf(END_MARK, i + START_MARK.length);
@@ -241,7 +318,9 @@ class SSHConnectionManager {
241
318
  // pty 下 stderr 与 stdout 合并,内容都在 stdout 里
242
319
  stderr: '',
243
320
  exitCode: parseInt(m[1], 10),
244
- note: '该会话已由 JumpServer 录像,可在控制台回放',
321
+ note: autoConfirmed
322
+ ? '命令经审批后执行;该会话已由 JumpServer 录像,可在控制台回放'
323
+ : '该会话已由 JumpServer 录像,可在控制台回放',
245
324
  });
246
325
  });
247
326