mcp-remote-ssh 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (27) hide show
  1. {mcp_remote_ssh-0.2.2/src/mcp_remote_ssh.egg-info → mcp_remote_ssh-0.3.0}/PKG-INFO +7 -6
  2. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/README.md +6 -5
  3. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/pyproject.toml +1 -1
  4. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/execute.py +25 -15
  5. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0/src/mcp_remote_ssh.egg-info}/PKG-INFO +7 -6
  6. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/tests/test_edge_cases.py +14 -15
  7. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/LICENSE +0 -0
  8. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/setup.cfg +0 -0
  9. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/__init__.py +0 -0
  10. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/lifespan.py +0 -0
  11. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/__init__.py +0 -0
  12. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/connection.py +0 -0
  13. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/forward.py +0 -0
  14. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/helpers.py +0 -0
  15. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/secrets.py +0 -0
  16. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/sftp.py +0 -0
  17. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/server/shell.py +0 -0
  18. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh/session.py +0 -0
  19. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh.egg-info/SOURCES.txt +0 -0
  20. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh.egg-info/dependency_links.txt +0 -0
  21. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh.egg-info/entry_points.txt +0 -0
  22. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh.egg-info/requires.txt +0 -0
  23. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/src/mcp_remote_ssh.egg-info/top_level.txt +0 -0
  24. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/tests/test_redaction.py +0 -0
  25. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/tests/test_secrets_integration.py +0 -0
  26. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/tests/test_secrets_load.py +0 -0
  27. {mcp_remote_ssh-0.2.2 → mcp_remote_ssh-0.3.0}/tests/test_secrets_parsing.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-remote-ssh
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: MCP server for remote SSH operations -- persistent sessions, structured command execution, SFTP file transfer, and port forwarding for AI agents.
5
5
  Author: Mohammadfaiz Bawa
6
6
  License-Expression: MIT
@@ -84,17 +84,17 @@ ssh_execute(session_id="abc", command="uname -a")
84
84
 
85
85
  1. **Local file read** -- the env file lives on your machine, never on the remote host
86
86
  2. **Shell injection via builtins** -- uses `read -r VAR <<< 'value' && export VAR` (no process tree exposure)
87
- 3. **Automatic redaction** -- every tool response (`ssh_execute`, `ssh_shell_send`, `ssh_shell_read`, `ssh_read_remote_file`) is scrubbed before reaching the LLM
88
- 4. **Longest-first matching** -- prevents partial-match corruption (e.g., `abc123` is replaced before `abc`)
89
- 5. **Works with exec channels** -- secrets are prepended as exports to `ssh_execute` commands so they're available in stateless channels too
87
+ 3. **Stdin-based exec injection** -- `ssh_execute` feeds secrets via stdin to a bash wrapper, so they never appear in `/proc/*/cmdline`
88
+ 4. **Automatic redaction** -- every tool response (`ssh_execute`, `ssh_shell_send`, `ssh_shell_read`, `ssh_read_remote_file`) is scrubbed before reaching the LLM
89
+ 5. **Longest-first matching** -- prevents partial-match corruption (e.g., `abc123` is replaced before `abc`)
90
90
 
91
91
  ### Security properties
92
92
 
93
93
  | Threat | Mitigated? | How |
94
94
  |--------|-----------|-----|
95
95
  | Secret in LLM context window | Yes | Output redaction replaces values with `***` |
96
- | Secret in remote process tree | Yes | Shell builtins (`read`/`export`) don't fork |
97
- | Secret in `ssh_execute` process tree | Partial | Single short-lived process; use shell for zero-exposure |
96
+ | Secret in remote process tree (shell) | Yes | Shell builtins (`read`/`export`) don't fork |
97
+ | Secret in remote process tree (exec) | Yes | Secrets fed via stdin, never in `/proc/*/cmdline` |
98
98
  | LLM tries `cat` on the env file | N/A | File is local-only, doesn't exist on remote |
99
99
  | LLM tries `echo $VAR` | Yes | Output is redacted |
100
100
  | Encoded/transformed secret (base64) | No | Only literal matches are redacted |
@@ -207,6 +207,7 @@ ssh_forward_port(session_id="a1b2c3d4", remote_port=5432, local_port=15432)
207
207
  Built on **[Paramiko](https://www.paramiko.org/)** (SSH) + **[FastMCP](https://github.com/PrefectHQ/fastmcp)** (MCP protocol).
208
208
 
209
209
  - `ssh_execute` uses `exec_command()` for clean structured output with real exit codes
210
+ - When secrets are loaded, `ssh_execute` feeds exports via stdin to a `bash` wrapper, then `exec`s the actual command -- secrets never appear in the process tree
210
211
  - `ssh_shell_*` uses `invoke_shell()` for persistent interactive sessions
211
212
  - All blocking Paramiko calls run in `run_in_executor` to stay async
212
213
  - Shell keeps a 500KB rolling buffer for `shell_read` polling
@@ -54,17 +54,17 @@ ssh_execute(session_id="abc", command="uname -a")
54
54
 
55
55
  1. **Local file read** -- the env file lives on your machine, never on the remote host
56
56
  2. **Shell injection via builtins** -- uses `read -r VAR <<< 'value' && export VAR` (no process tree exposure)
57
- 3. **Automatic redaction** -- every tool response (`ssh_execute`, `ssh_shell_send`, `ssh_shell_read`, `ssh_read_remote_file`) is scrubbed before reaching the LLM
58
- 4. **Longest-first matching** -- prevents partial-match corruption (e.g., `abc123` is replaced before `abc`)
59
- 5. **Works with exec channels** -- secrets are prepended as exports to `ssh_execute` commands so they're available in stateless channels too
57
+ 3. **Stdin-based exec injection** -- `ssh_execute` feeds secrets via stdin to a bash wrapper, so they never appear in `/proc/*/cmdline`
58
+ 4. **Automatic redaction** -- every tool response (`ssh_execute`, `ssh_shell_send`, `ssh_shell_read`, `ssh_read_remote_file`) is scrubbed before reaching the LLM
59
+ 5. **Longest-first matching** -- prevents partial-match corruption (e.g., `abc123` is replaced before `abc`)
60
60
 
61
61
  ### Security properties
62
62
 
63
63
  | Threat | Mitigated? | How |
64
64
  |--------|-----------|-----|
65
65
  | Secret in LLM context window | Yes | Output redaction replaces values with `***` |
66
- | Secret in remote process tree | Yes | Shell builtins (`read`/`export`) don't fork |
67
- | Secret in `ssh_execute` process tree | Partial | Single short-lived process; use shell for zero-exposure |
66
+ | Secret in remote process tree (shell) | Yes | Shell builtins (`read`/`export`) don't fork |
67
+ | Secret in remote process tree (exec) | Yes | Secrets fed via stdin, never in `/proc/*/cmdline` |
68
68
  | LLM tries `cat` on the env file | N/A | File is local-only, doesn't exist on remote |
69
69
  | LLM tries `echo $VAR` | Yes | Output is redacted |
70
70
  | Encoded/transformed secret (base64) | No | Only literal matches are redacted |
@@ -177,6 +177,7 @@ ssh_forward_port(session_id="a1b2c3d4", remote_port=5432, local_port=15432)
177
177
  Built on **[Paramiko](https://www.paramiko.org/)** (SSH) + **[FastMCP](https://github.com/PrefectHQ/fastmcp)** (MCP protocol).
178
178
 
179
179
  - `ssh_execute` uses `exec_command()` for clean structured output with real exit codes
180
+ - When secrets are loaded, `ssh_execute` feeds exports via stdin to a `bash` wrapper, then `exec`s the actual command -- secrets never appear in the process tree
180
181
  - `ssh_shell_*` uses `invoke_shell()` for persistent interactive sessions
181
182
  - All blocking Paramiko calls run in `run_in_executor` to stay async
182
183
  - Shell keeps a 500KB rolling buffer for `shell_read` polling
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-remote-ssh"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "MCP server for remote SSH operations -- persistent sessions, structured command execution, SFTP file transfer, and port forwarding for AI agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -11,16 +11,15 @@ from mcp_remote_ssh.server import mcp
11
11
  from mcp_remote_ssh.server.helpers import get_session, require_connected
12
12
 
13
13
 
14
- def _build_env_prefix(session) -> str:
15
- """Build an env export prefix from loaded secrets for exec channels.
16
- Uses env var assignment syntax that doesn't appear in /proc/*/cmdline
17
- since the whole thing runs under a single 'bash -c' invocation."""
14
+ def _build_env_script(session) -> str:
15
+ """Build export statements for secrets, to be fed via stdin.
16
+ Never appears in /proc/*/cmdline."""
18
17
  if not session._secrets:
19
18
  return ''
20
- parts = []
19
+ lines = []
21
20
  for key, value in session._secrets.items():
22
- parts.append(f'export {key}={shlex.quote(value)}')
23
- return ' && '.join(parts) + ' && '
21
+ lines.append(f'export {key}={shlex.quote(value)}')
22
+ return '\n'.join(lines) + '\n'
24
23
 
25
24
 
26
25
  @mcp.tool()
@@ -48,13 +47,18 @@ async def ssh_execute(
48
47
  session = get_session(ctx, session_id)
49
48
  require_connected(session)
50
49
 
51
- env_prefix = _build_env_prefix(session)
52
- full_command = env_prefix + command
53
-
50
+ env_script = _build_env_script(session)
54
51
  loop = asyncio.get_running_loop()
55
52
 
56
53
  def _exec() -> dict[str, Any]:
57
- _, stdout_ch, stderr_ch = session.client.exec_command(full_command, timeout=timeout)
54
+ if env_script:
55
+ # Feed secrets via stdin so they never appear in /proc/*/cmdline
56
+ script = env_script + f'exec bash -c {shlex.quote(command)}\n'
57
+ stdin_ch, stdout_ch, stderr_ch = session.client.exec_command('bash', timeout=timeout)
58
+ stdin_ch.write(script)
59
+ stdin_ch.channel.shutdown_write()
60
+ else:
61
+ _, stdout_ch, stderr_ch = session.client.exec_command(command, timeout=timeout)
58
62
  stdout = stdout_ch.read().decode(errors='replace')
59
63
  stderr = stderr_ch.read().decode(errors='replace')
60
64
  exit_code = stdout_ch.channel.recv_exit_status()
@@ -93,17 +97,23 @@ async def ssh_sudo_execute(
93
97
  session = get_session(ctx, session_id)
94
98
  require_connected(session)
95
99
 
96
- env_prefix = _build_env_prefix(session)
100
+ env_script = _build_env_script(session)
97
101
 
98
102
  if sudo_password:
99
- wrapped = f'{env_prefix}echo {_shell_quote(sudo_password)} | sudo -S {command}'
103
+ actual_cmd = f'echo {_shell_quote(sudo_password)} | sudo -SE {command}'
100
104
  else:
101
- wrapped = f'{env_prefix}sudo -E {command}'
105
+ actual_cmd = f'sudo -E {command}'
102
106
 
103
107
  loop = asyncio.get_running_loop()
104
108
 
105
109
  def _exec() -> dict[str, Any]:
106
- _, stdout_ch, stderr_ch = session.client.exec_command(wrapped, timeout=timeout)
110
+ if env_script:
111
+ script = env_script + f'exec bash -c {shlex.quote(actual_cmd)}\n'
112
+ stdin_ch, stdout_ch, stderr_ch = session.client.exec_command('bash', timeout=timeout)
113
+ stdin_ch.write(script)
114
+ stdin_ch.channel.shutdown_write()
115
+ else:
116
+ _, stdout_ch, stderr_ch = session.client.exec_command(actual_cmd, timeout=timeout)
107
117
  stdout = stdout_ch.read().decode(errors='replace')
108
118
  stderr = stderr_ch.read().decode(errors='replace')
109
119
  exit_code = stdout_ch.channel.recv_exit_status()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-remote-ssh
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: MCP server for remote SSH operations -- persistent sessions, structured command execution, SFTP file transfer, and port forwarding for AI agents.
5
5
  Author: Mohammadfaiz Bawa
6
6
  License-Expression: MIT
@@ -84,17 +84,17 @@ ssh_execute(session_id="abc", command="uname -a")
84
84
 
85
85
  1. **Local file read** -- the env file lives on your machine, never on the remote host
86
86
  2. **Shell injection via builtins** -- uses `read -r VAR <<< 'value' && export VAR` (no process tree exposure)
87
- 3. **Automatic redaction** -- every tool response (`ssh_execute`, `ssh_shell_send`, `ssh_shell_read`, `ssh_read_remote_file`) is scrubbed before reaching the LLM
88
- 4. **Longest-first matching** -- prevents partial-match corruption (e.g., `abc123` is replaced before `abc`)
89
- 5. **Works with exec channels** -- secrets are prepended as exports to `ssh_execute` commands so they're available in stateless channels too
87
+ 3. **Stdin-based exec injection** -- `ssh_execute` feeds secrets via stdin to a bash wrapper, so they never appear in `/proc/*/cmdline`
88
+ 4. **Automatic redaction** -- every tool response (`ssh_execute`, `ssh_shell_send`, `ssh_shell_read`, `ssh_read_remote_file`) is scrubbed before reaching the LLM
89
+ 5. **Longest-first matching** -- prevents partial-match corruption (e.g., `abc123` is replaced before `abc`)
90
90
 
91
91
  ### Security properties
92
92
 
93
93
  | Threat | Mitigated? | How |
94
94
  |--------|-----------|-----|
95
95
  | Secret in LLM context window | Yes | Output redaction replaces values with `***` |
96
- | Secret in remote process tree | Yes | Shell builtins (`read`/`export`) don't fork |
97
- | Secret in `ssh_execute` process tree | Partial | Single short-lived process; use shell for zero-exposure |
96
+ | Secret in remote process tree (shell) | Yes | Shell builtins (`read`/`export`) don't fork |
97
+ | Secret in remote process tree (exec) | Yes | Secrets fed via stdin, never in `/proc/*/cmdline` |
98
98
  | LLM tries `cat` on the env file | N/A | File is local-only, doesn't exist on remote |
99
99
  | LLM tries `echo $VAR` | Yes | Output is redacted |
100
100
  | Encoded/transformed secret (base64) | No | Only literal matches are redacted |
@@ -207,6 +207,7 @@ ssh_forward_port(session_id="a1b2c3d4", remote_port=5432, local_port=15432)
207
207
  Built on **[Paramiko](https://www.paramiko.org/)** (SSH) + **[FastMCP](https://github.com/PrefectHQ/fastmcp)** (MCP protocol).
208
208
 
209
209
  - `ssh_execute` uses `exec_command()` for clean structured output with real exit codes
210
+ - When secrets are loaded, `ssh_execute` feeds exports via stdin to a `bash` wrapper, then `exec`s the actual command -- secrets never appear in the process tree
210
211
  - `ssh_shell_*` uses `invoke_shell()` for persistent interactive sessions
211
212
  - All blocking Paramiko calls run in `run_in_executor` to stay async
212
213
  - Shell keeps a 500KB rolling buffer for `shell_read` polling
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  import pytest
5
5
 
6
6
  from mcp_remote_ssh.session import SSHSession
7
- from mcp_remote_ssh.server.execute import _build_env_prefix
7
+ from mcp_remote_ssh.server.execute import _build_env_script
8
8
 
9
9
 
10
10
  class TestLongestFirstRedaction:
@@ -44,35 +44,34 @@ class TestLongestFirstRedaction:
44
44
  assert redacted == 'a=*** b=***'
45
45
 
46
46
 
47
- class TestEnvPrefixForExec:
48
- """Verify _build_env_prefix generates correct export statements."""
47
+ class TestEnvScriptForExec:
48
+ """Verify _build_env_script generates correct export statements."""
49
49
 
50
50
  def test_empty_when_no_secrets(self):
51
51
  session = SSHSession(host='test-host')
52
- assert _build_env_prefix(session) == ''
52
+ assert _build_env_script(session) == ''
53
53
 
54
54
  def test_single_secret(self):
55
55
  session = SSHSession(host='test-host')
56
56
  session._secrets = {'TOKEN': 'abc123'}
57
- prefix = _build_env_prefix(session)
58
- assert 'export TOKEN=abc123' in prefix
59
- assert prefix.endswith(' && ')
57
+ script = _build_env_script(session)
58
+ assert 'export TOKEN=abc123' in script
59
+ assert script.endswith('\n')
60
60
 
61
61
  def test_multiple_secrets(self):
62
62
  session = SSHSession(host='test-host')
63
63
  session._secrets = {'A': 'val1', 'B': 'val2'}
64
- prefix = _build_env_prefix(session)
65
- assert 'export A=val1' in prefix
66
- assert 'export B=val2' in prefix
67
- assert prefix.endswith(' && ')
64
+ script = _build_env_script(session)
65
+ assert 'export A=val1' in script
66
+ assert 'export B=val2' in script
67
+ assert '\n' in script
68
68
 
69
69
  def test_quotes_special_characters(self):
70
70
  session = SSHSession(host='test-host')
71
71
  session._secrets = {'PASS': "it's a p@ss!"}
72
- prefix = _build_env_prefix(session)
73
- # shlex.quote wraps with single quotes and escapes internal ones
74
- assert 'PASS=' in prefix
75
- assert "p@ss!" in prefix
72
+ script = _build_env_script(session)
73
+ assert 'PASS=' in script
74
+ assert "p@ss!" in script
76
75
 
77
76
 
78
77
  class TestShellInjectionSafety:
File without changes
File without changes