Skip to content

Commit a984e92

Browse files
committed
Merge branch 'refactor/shell-executor' into develop
2 parents d383539 + b993032 commit a984e92

26 files changed

+2390
-1355
lines changed

.github/workflows/publish.yml

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
name: Publish
2+
23
on:
34
push:
45
tags:
56
- "v*"
6-
strategy:
7-
matrix:
8-
python-version: ["3.11"]
97

108
jobs:
119
test:
1210
runs-on: ubuntu-latest
11+
strategy:
12+
matrix:
13+
python-version: ["3.11"]
14+
1315
steps:
1416
- uses: actions/checkout@v4
1517

@@ -23,14 +25,13 @@ jobs:
2325
python -m pip install --upgrade pip
2426
pip install uv
2527
26-
- name: Install dev/test dependencies
28+
- name: Install dependencies
2729
run: |
28-
pip install -e ".[dev]"
29-
pip install -e ".[test]"
30+
uv pip install -e ".[dev,test]"
3031
31-
- name: Run tests
32+
- name: Run tests and checks
3233
run: |
33-
make check
34+
make all
3435
3536
publish:
3637
needs: test
@@ -44,14 +45,13 @@ jobs:
4445

4546
- name: Update version from tag
4647
run: |
47-
# Strip 'v' prefix from tag and update version.py
4848
VERSION=${GITHUB_REF#refs/tags/v}
4949
echo "__version__ = \"${VERSION}\"" > src/mcp_shell_server/version.py
5050
51-
- name: Set up Python ${{ matrix.python-version }}
51+
- name: Set up Python 3.11
5252
uses: actions/setup-python@v5
5353
with:
54-
python-version: ${{ matrix.python-version }}
54+
python-version: "3.11"
5555

5656
- name: Install uv
5757
run: |
@@ -60,10 +60,11 @@ jobs:
6060
6161
- name: Build package
6262
run: |
63-
uv build
63+
uv pip install build
64+
python -m build
6465
6566
- name: Publish to PyPI
66-
env:
67-
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
68-
run: |
69-
uv publish --token $PYPI_TOKEN
67+
uses: pypa/gh-action-pypi-publish@release/v1
68+
with:
69+
password: ${{ secrets.PYPI_TOKEN }}
70+
password: ${{ secrets.PYPI_TOKEN }}

.github/workflows/test.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,17 @@ jobs:
2626
python -m pip install --upgrade pip
2727
pip install uv
2828
29-
- name: Install dev/test dependencies
29+
- name: Install dependencies
3030
run: |
31-
pip install -e ".[dev]"
32-
pip install -e ".[test]"
31+
uv pip install -e ".[dev,test]"
3332
34-
- name: Run lint and typecheck
33+
- name: Run format checks and typecheck
3534
run: |
36-
make lint typecheck
35+
make check
3736
3837
- name: Run tests with coverage
3938
run: |
40-
pytest --cov --cov-report=xml
39+
make coverage
4140
4241
- name: Upload coverage to Codecov
4342
uses: codecov/codecov-action@v5

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,15 @@ asyncio_mode = "strict"
4141
testpaths = "tests"
4242
# Set default event loop scope for async tests
4343
asyncio_default_fixture_loop_scope = "function"
44+
markers = [
45+
"macos: marks tests that should only run on macOS",
46+
"slow: marks tests as slow running",
47+
]
4448
filterwarnings = [
4549
"ignore::RuntimeWarning:selectors:",
4650
"ignore::pytest.PytestUnhandledCoroutineWarning:",
4751
"ignore::pytest.PytestUnraisableExceptionWarning:",
52+
"ignore::DeprecationWarning:pytest_asyncio.plugin:",
4853
]
4954

5055
[tool.ruff]
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import shlex
2+
from typing import Dict, List, Tuple, Union
3+
4+
5+
class CommandPreProcessor:
6+
"""
7+
Pre-processes and validates shell commands before execution
8+
"""
9+
10+
def preprocess_command(self, command: List[str]) -> List[str]:
11+
"""
12+
Preprocess the command to handle cases where '|' is attached to a command.
13+
"""
14+
preprocessed_command = []
15+
for token in command:
16+
if token in ["||", "&&", ";"]: # Special shell operators
17+
preprocessed_command.append(token)
18+
elif "|" in token and token != "|":
19+
parts = token.split("|")
20+
preprocessed_command.extend(
21+
[part.strip() for part in parts if part.strip()]
22+
)
23+
preprocessed_command.append("|")
24+
else:
25+
preprocessed_command.append(token)
26+
return preprocessed_command
27+
28+
def clean_command(self, command: List[str]) -> List[str]:
29+
"""
30+
Clean command by trimming whitespace from each part.
31+
Removes empty strings but preserves arguments that are meant to be spaces.
32+
33+
Args:
34+
command (List[str]): Original command and its arguments
35+
36+
Returns:
37+
List[str]: Cleaned command
38+
"""
39+
return [arg for arg in command if arg] # Remove empty strings
40+
41+
def create_shell_command(self, command: List[str]) -> str:
42+
"""
43+
Create a shell command string from a list of arguments.
44+
Handles wildcards and arguments properly.
45+
"""
46+
if not command:
47+
return ""
48+
49+
escaped_args = []
50+
for arg in command:
51+
if arg.isspace():
52+
# Wrap space-only arguments in single quotes
53+
escaped_args.append(f"'{arg}'")
54+
else:
55+
# Properly escape all arguments including those with wildcards
56+
escaped_args.append(shlex.quote(arg.strip()))
57+
58+
return " ".join(escaped_args)
59+
60+
def split_pipe_commands(self, command: List[str]) -> List[List[str]]:
61+
"""
62+
Split commands by pipe operator into separate commands.
63+
64+
Args:
65+
command (List[str]): Command and its arguments with pipe operators
66+
67+
Returns:
68+
List[List[str]]: List of commands split by pipe operator
69+
"""
70+
commands: List[List[str]] = []
71+
current_command: List[str] = []
72+
73+
for arg in command:
74+
if arg.strip() == "|":
75+
if current_command:
76+
commands.append(current_command)
77+
current_command = []
78+
else:
79+
current_command.append(arg)
80+
81+
if current_command:
82+
commands.append(current_command)
83+
84+
return commands
85+
86+
def parse_command(
87+
self, command: List[str]
88+
) -> Tuple[List[str], Dict[str, Union[None, str, bool]]]:
89+
"""
90+
Parse command and extract redirections.
91+
"""
92+
cmd = []
93+
redirects: Dict[str, Union[None, str, bool]] = {
94+
"stdin": None,
95+
"stdout": None,
96+
"stdout_append": False,
97+
}
98+
99+
i = 0
100+
while i < len(command):
101+
token = command[i]
102+
103+
# Shell operators check
104+
if token in ["|", ";", "&&", "||"]:
105+
raise ValueError(f"Unexpected shell operator: {token}")
106+
107+
# Output redirection
108+
if token in [">", ">>"]:
109+
if i + 1 >= len(command):
110+
raise ValueError("Missing path for output redirection")
111+
if i + 1 < len(command) and command[i + 1] in [">", ">>", "<"]:
112+
raise ValueError("Invalid redirection target: operator found")
113+
path = command[i + 1]
114+
redirects["stdout"] = path
115+
redirects["stdout_append"] = token == ">>"
116+
i += 2
117+
continue
118+
119+
# Input redirection
120+
if token == "<":
121+
if i + 1 >= len(command):
122+
raise ValueError("Missing path for input redirection")
123+
path = command[i + 1]
124+
redirects["stdin"] = path
125+
i += 2
126+
continue
127+
128+
cmd.append(token)
129+
i += 1
130+
131+
return cmd, redirects
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
Provides validation for shell commands and ensures they are allowed to be executed.
3+
"""
4+
5+
import os
6+
from typing import Dict, List
7+
8+
9+
class CommandValidator:
10+
"""
11+
Validates shell commands against a whitelist and checks for unsafe operators.
12+
"""
13+
14+
def __init__(self):
15+
"""
16+
Initialize the validator.
17+
"""
18+
pass
19+
20+
def _get_allowed_commands(self) -> set[str]:
21+
"""Get the set of allowed commands from environment variables"""
22+
allow_commands = os.environ.get("ALLOW_COMMANDS", "")
23+
allowed_commands = os.environ.get("ALLOWED_COMMANDS", "")
24+
commands = allow_commands + "," + allowed_commands
25+
return {cmd.strip() for cmd in commands.split(",") if cmd.strip()}
26+
27+
def get_allowed_commands(self) -> list[str]:
28+
"""Get the list of allowed commands from environment variables"""
29+
return list(self._get_allowed_commands())
30+
31+
def is_command_allowed(self, command: str) -> bool:
32+
"""Check if a command is in the allowed list"""
33+
cmd = command.strip()
34+
return cmd in self._get_allowed_commands()
35+
36+
def validate_no_shell_operators(self, cmd: str) -> None:
37+
"""
38+
Validate that the command does not contain shell operators.
39+
40+
Args:
41+
cmd (str): Command to validate
42+
43+
Raises:
44+
ValueError: If the command contains shell operators
45+
"""
46+
if cmd in [";", "&&", "||", "|"]:
47+
raise ValueError(f"Unexpected shell operator: {cmd}")
48+
49+
def validate_pipeline(self, commands: List[str]) -> Dict[str, str]:
50+
"""
51+
Validate pipeline command and ensure all parts are allowed.
52+
53+
Args:
54+
commands (List[str]): List of commands to validate
55+
56+
Returns:
57+
Dict[str, str]: Error message if validation fails, empty dict if success
58+
59+
Raises:
60+
ValueError: If validation fails
61+
"""
62+
current_cmd: List[str] = []
63+
64+
for token in commands:
65+
if token == "|":
66+
if not current_cmd:
67+
raise ValueError("Empty command before pipe operator")
68+
if not self.is_command_allowed(current_cmd[0]):
69+
raise ValueError(f"Command not allowed: {current_cmd[0]}")
70+
current_cmd = []
71+
elif token in [";", "&&", "||"]:
72+
raise ValueError(f"Unexpected shell operator in pipeline: {token}")
73+
else:
74+
current_cmd.append(token)
75+
76+
if current_cmd:
77+
if not self.is_command_allowed(current_cmd[0]):
78+
raise ValueError(f"Command not allowed: {current_cmd[0]}")
79+
80+
return {}
81+
82+
def validate_command(self, command: List[str]) -> None:
83+
"""
84+
Validate if the command is allowed to be executed.
85+
86+
Args:
87+
command (List[str]): Command and its arguments
88+
89+
Raises:
90+
ValueError: If the command is empty, not allowed, or contains invalid shell operators
91+
"""
92+
if not command:
93+
raise ValueError("Empty command")
94+
95+
allowed_commands = self._get_allowed_commands()
96+
if not allowed_commands:
97+
raise ValueError(
98+
"No commands are allowed. Please set ALLOW_COMMANDS environment variable."
99+
)
100+
101+
# Clean and check the first command
102+
cleaned_cmd = command[0].strip()
103+
if cleaned_cmd not in allowed_commands:
104+
raise ValueError(f"Command not allowed: {cleaned_cmd}")

0 commit comments

Comments
 (0)