Skip to content

Commit 0125f2c

Browse files
committed
feat(shell_executor.py): add _clean_command method to trim whitespace from command arguments for improved command validation
fix(shell_executor.py): update command validation to use cleaned command and handle empty command case test(shell_executor.py): add tests for command execution with leading/trailing spaces and empty command handling
1 parent 3be440a commit 0125f2c

File tree

2 files changed

+50
-12
lines changed

2 files changed

+50
-12
lines changed

mcp_shell_server/shell_executor.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ def _get_allowed_commands(self) -> set:
2626
allow_commands = os.environ.get("ALLOW_COMMANDS", "")
2727
return {cmd.strip() for cmd in allow_commands.split(",") if cmd.strip()}
2828

29+
def _clean_command(self, command: List[str]) -> List[str]:
30+
"""
31+
Clean command by trimming whitespace from each part.
32+
33+
Args:
34+
command (List[str]): Original command and its arguments
35+
36+
Returns:
37+
List[str]: Cleaned command with trimmed whitespace
38+
"""
39+
return [arg.strip() for arg in command if arg.strip()]
40+
2941
def _validate_command(self, command: List[str]) -> None:
3042
"""
3143
Validate if the command is allowed to be executed.
@@ -43,17 +55,20 @@ def _validate_command(self, command: List[str]) -> None:
4355
if not allowed_commands:
4456
raise ValueError("No commands are allowed. Please set ALLOW_COMMANDS environment variable.")
4557

46-
if command[0] not in allowed_commands:
47-
raise ValueError(f"Command not allowed: {command[0]}")
58+
# Clean and check the first command
59+
cleaned_cmd = command[0].strip()
60+
if cleaned_cmd not in allowed_commands:
61+
raise ValueError(f"Command not allowed: {cleaned_cmd}")
4862

4963
# Check for shell operators and validate subsequent commands
5064
for i, arg in enumerate(command[1:], start=1):
51-
if arg in [";", "&&", "||", "|"]:
65+
cleaned_arg = arg.strip()
66+
if cleaned_arg in [";", "&&", "||", "|"]:
5267
if i + 1 >= len(command):
53-
raise ValueError(f"Unexpected shell operator: {arg}")
54-
next_cmd = command[i + 1]
68+
raise ValueError(f"Unexpected shell operator: {cleaned_arg}")
69+
next_cmd = command[i + 1].strip()
5570
if next_cmd not in allowed_commands:
56-
raise ValueError(f"Command not allowed after {arg}: {next_cmd}")
71+
raise ValueError(f"Command not allowed after {cleaned_arg}: {next_cmd}")
5772

5873
async def execute(self, command: List[str], stdin: Optional[str] = None) -> Dict[str, Any]:
5974
"""
@@ -70,7 +85,12 @@ async def execute(self, command: List[str], stdin: Optional[str] = None) -> Dict
7085
start_time = time.time()
7186

7287
try:
73-
self._validate_command(command)
88+
# Clean command before validation and execution
89+
cleaned_command = self._clean_command(command)
90+
if not cleaned_command:
91+
raise ValueError("Empty command")
92+
93+
self._validate_command(cleaned_command)
7494
except ValueError as e:
7595
return {
7696
"error": str(e),
@@ -82,8 +102,8 @@ async def execute(self, command: List[str], stdin: Optional[str] = None) -> Dict
82102

83103
try:
84104
process = await asyncio.create_subprocess_exec(
85-
command[0],
86-
*command[1:],
105+
cleaned_command[0],
106+
*cleaned_command[1:],
87107
stdin=asyncio.subprocess.PIPE if stdin else None,
88108
stdout=asyncio.subprocess.PIPE,
89109
stderr=asyncio.subprocess.PIPE,
@@ -94,17 +114,18 @@ async def execute(self, command: List[str], stdin: Optional[str] = None) -> Dict
94114
stdout, stderr = await process.communicate(input=stdin_bytes)
95115

96116
return {
117+
"error": None, # Set error field to None for success case
97118
"stdout": stdout.decode() if stdout else "",
98119
"stderr": stderr.decode() if stderr else "",
99120
"status": process.returncode,
100121
"execution_time": time.time() - start_time
101122
}
102123
except FileNotFoundError:
103124
return {
104-
"error": f"Command not found: {command[0]}",
125+
"error": f"Command not found: {cleaned_command[0]}",
105126
"status": 1,
106127
"stdout": "",
107-
"stderr": f"Command not found: {command[0]}",
128+
"stderr": f"Command not found: {cleaned_command[0]}",
108129
"execution_time": time.time() - start_time
109130
}
110131
except Exception as e:

tests/test_shell_executor.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,73 @@
11
import pytest
22
from mcp_shell_server.shell_executor import ShellExecutor
33

4+
45
@pytest.fixture
56
def executor():
67
return ShellExecutor()
78

9+
810
@pytest.mark.asyncio
911
async def test_basic_command_execution(executor, monkeypatch):
1012
monkeypatch.setenv("ALLOW_COMMANDS", "echo")
1113
result = await executor.execute(["echo", "hello"])
1214
assert result["stdout"].strip() == "hello"
1315
assert result["status"] == 0
1416

17+
1518
@pytest.mark.asyncio
1619
async def test_stdin_input(executor, monkeypatch):
1720
monkeypatch.setenv("ALLOW_COMMANDS", "cat")
1821
result = await executor.execute(["cat"], stdin="hello world")
1922
assert result["stdout"].strip() == "hello world"
2023
assert result["status"] == 0
2124

25+
26+
@pytest.mark.asyncio
27+
async def test_command_with_space_allowed(executor, monkeypatch):
28+
monkeypatch.setenv("ALLOW_COMMANDS", "cat")
29+
result = await executor.execute(["cat "], stdin="hello world")
30+
assert result["error"] == None
31+
assert result["stdout"].strip() == "hello world"
32+
assert result["status"] == 0
33+
34+
2235
@pytest.mark.asyncio
2336
async def test_command_not_allowed(executor, monkeypatch):
2437
monkeypatch.setenv("ALLOW_COMMANDS", "ls")
2538
result = await executor.execute(["rm", "-rf", "/"])
2639
assert result["error"] == "Command not allowed: rm"
2740
assert result["status"] == 1
2841

42+
2943
@pytest.mark.asyncio
3044
async def test_empty_command(executor):
3145
result = await executor.execute([])
3246
assert result["error"] == "Empty command"
3347
assert result["status"] == 1
3448

49+
3550
@pytest.mark.asyncio
3651
async def test_command_with_space_in_allow_commands(executor, monkeypatch):
3752
monkeypatch.setenv("ALLOW_COMMANDS", "ls, echo ,cat")
3853
result = await executor.execute(["echo", "test"])
3954
assert result["stdout"].strip() == "test"
4055
assert result["status"] == 0
4156

57+
4258
@pytest.mark.asyncio
4359
async def test_multiple_commands_with_operator(executor, monkeypatch):
4460
monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls")
4561
result = await executor.execute(["echo", "hello", ";"])
4662
assert result["error"] == "Unexpected shell operator: ;"
4763
assert result["status"] == 1
4864

65+
4966
@pytest.mark.asyncio
5067
async def test_shell_operators_not_allowed(executor, monkeypatch):
5168
monkeypatch.setenv("ALLOW_COMMANDS", "echo,ls")
5269
operators = [";", "&&", "||", "|"]
5370
for op in operators:
5471
result = await executor.execute(["echo", "hello", op])
5572
assert result["error"] == f"Unexpected shell operator: {op}"
56-
assert result["status"] == 1
73+
assert result["status"] == 1

0 commit comments

Comments
 (0)