Compare commits

..

2 Commits

Author SHA1 Message Date
Alex
7780565f1b fix: address review comments for MCP stdio command allowlist
- Remove unused pytest import from test_stdio_config.py
- Strip file extension from base_command for Windows compatibility
  (e.g., python.exe -> python) using os.path.splitext

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-02 13:14:00 -07:00
Iris Clawd
52c4e4a309 feat: add command allowlist validation for MCP stdio transport
Add an optional allowed_commands parameter to StdioTransport that
validates the command basename against an allowlist before spawning
a subprocess. This provides defense-in-depth against configuration-
driven command injection as MCP server discovery becomes more dynamic.

- DEFAULT_ALLOWED_COMMANDS includes common runtimes: python, python3,
  node, npx, uvx, uv, deno, docker
- Validation checks os.path.basename(command) for cross-platform support
- Users can extend the allowlist, pass a custom set, or set
  allowed_commands=None to disable the check entirely
- No breaking change: all currently documented MCP server examples use
  commands in the default allowlist
- MCPServerStdio config model updated with allowed_commands field
- tool_resolver passes allowed_commands through to StdioTransport

Closes #5080
2026-04-02 13:13:43 -07:00
16 changed files with 195 additions and 346 deletions

View File

@@ -1,8 +0,0 @@
---
title: "POST /stop/{kickoff_id}"
description: "إيقاف تنفيذ الطاقم الجاري"
openapi: "/enterprise-api.en.yaml POST /stop/{kickoff_id}"
mode: "wide"
---

View File

@@ -495,8 +495,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -965,8 +964,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -1435,8 +1433,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -1905,8 +1902,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -2376,8 +2372,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -2845,8 +2840,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -3317,8 +3311,7 @@
"en/api-reference/inputs",
"en/api-reference/kickoff",
"en/api-reference/resume",
"en/api-reference/status",
"en/api-reference/stop"
"en/api-reference/status"
]
}
]
@@ -3803,8 +3796,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -4258,8 +4250,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -4713,8 +4704,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -5168,8 +5158,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -5622,8 +5611,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -6076,8 +6064,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -6531,8 +6518,7 @@
"pt-BR/api-reference/inputs",
"pt-BR/api-reference/kickoff",
"pt-BR/api-reference/resume",
"pt-BR/api-reference/status",
"pt-BR/api-reference/stop"
"pt-BR/api-reference/status"
]
}
]
@@ -7029,8 +7015,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -7496,8 +7481,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -7963,8 +7947,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -8430,8 +8413,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -8896,8 +8878,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -9362,8 +9343,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -9829,8 +9809,7 @@
"ko/api-reference/inputs",
"ko/api-reference/kickoff",
"ko/api-reference/resume",
"ko/api-reference/status",
"ko/api-reference/stop"
"ko/api-reference/status"
]
}
]
@@ -10327,8 +10306,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]
@@ -10794,8 +10772,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]
@@ -11261,8 +11238,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]
@@ -11728,8 +11704,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]
@@ -12194,8 +12169,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]
@@ -12660,8 +12634,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]
@@ -13127,8 +13100,7 @@
"ar/api-reference/inputs",
"ar/api-reference/kickoff",
"ar/api-reference/resume",
"ar/api-reference/status",
"ar/api-reference/stop"
"ar/api-reference/status"
]
}
]

View File

@@ -1,8 +0,0 @@
---
title: "POST /stop/{kickoff_id}"
description: "Stop a running crew execution"
openapi: "/enterprise-api.en.yaml POST /stop/{kickoff_id}"
mode: "wide"
---

View File

@@ -146,36 +146,6 @@ curl -X GET \
https://your-crew-url.crewai.com/status/abcd1234-5678-90ef-ghij-klmnopqrstuv
```
## Stopping a Running Execution
You can stop or cancel a running crew or flow execution at any time using the stop endpoint. This is useful when you need to abort a long-running execution or cancel one that is no longer needed.
### Stop an Execution
Send a POST request with the `kickoff_id` of the execution you want to stop:
```bash
curl -X POST \
-H "Authorization: Bearer YOUR_CREW_TOKEN" \
https://your-crew-url.crewai.com/stop/abcd1234-5678-90ef-ghij-klmnopqrstuv
```
**Success Response:**
```json
{"status": "stopped", "kickoffId": "abcd1234-5678-90ef-ghij-klmnopqrstuv"}
```
**Error Response** (when the execution has already finished):
```json
{"detail": "Cannot stop execution. Current state: SUCCESS"}
```
<Note>
You cannot stop executions that have already completed (`SUCCESS`), failed (`FAILURE`), or been revoked (`REVOKED`). The API returns a `400` status code in those cases.
</Note>
## Handling Executions
### Long-Running Executions

View File

@@ -36,7 +36,6 @@ info:
1. **Discover inputs** using `GET /inputs`
2. **Start execution** using `POST /kickoff`
3. **Monitor progress** using `GET /{kickoff_id}/status`
4. **Stop execution** (if needed) using `POST /stop/{kickoff_id}`
version: 1.0.0
contact:
name: CrewAI Support
@@ -285,56 +284,6 @@ paths:
"500":
$ref: "#/components/responses/ServerError"
/stop/{kickoff_id}:
post:
summary: Stop Crew Execution
description: |
**📋 Reference Example Only** - *This shows the request format. To test with your actual crew, copy the cURL example and replace the URL + token with your real values.*
Stops or cancels a running crew or flow execution. The execution must be in an active state
(not SUCCESS, FAILURE, or REVOKED).
operationId: stopCrewExecution
parameters:
- name: kickoff_id
in: path
required: true
description: The kickoff ID of the execution to stop
schema:
type: string
format: uuid
example: "abcd1234-5678-90ef-ghij-klmnopqrstuv"
responses:
"200":
description: Successfully stopped the execution
content:
application/json:
schema:
$ref: "#/components/schemas/StopExecutionResponse"
example:
status: "stopped"
kickoffId: "abcd1234-5678-90ef-ghij-klmnopqrstuv"
"400":
description: Execution is already in a terminal state (SUCCESS, FAILURE, or REVOKED)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
detail: "Cannot stop execution. Current state: SUCCESS"
"401":
$ref: "#/components/responses/UnauthorizedError"
"404":
description: Kickoff ID not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: "Execution not found"
message: "No execution found with ID: abcd1234-5678-90ef-ghij-klmnopqrstuv"
"500":
$ref: "#/components/responses/ServerError"
/resume:
post:
summary: Resume Crew Execution with Human Feedback
@@ -559,19 +508,6 @@ components:
description: Time taken to execute this task in seconds
example: 45.2
StopExecutionResponse:
type: object
properties:
status:
type: string
enum: ["stopped"]
description: Indicates the execution was successfully stopped
example: "stopped"
kickoffId:
type: string
description: The kickoff ID of the stopped execution
example: "abcd1234-5678-90ef-ghij-klmnopqrstuv"
Error:
type: object
properties:

View File

@@ -36,7 +36,6 @@ info:
1. **Discover inputs** using `GET /inputs`
2. **Start execution** using `POST /kickoff`
3. **Monitor progress** using `GET /{kickoff_id}/status`
4. **Stop execution** (if needed) using `POST /stop/{kickoff_id}`
version: 1.0.0
contact:
name: CrewAI Support
@@ -285,56 +284,6 @@ paths:
"500":
$ref: "#/components/responses/ServerError"
/stop/{kickoff_id}:
post:
summary: Stop Crew Execution
description: |
**📋 Reference Example Only** - *This shows the request format. To test with your actual crew, copy the cURL example and replace the URL + token with your real values.*
Stops or cancels a running crew or flow execution. The execution must be in an active state
(not SUCCESS, FAILURE, or REVOKED).
operationId: stopCrewExecution
parameters:
- name: kickoff_id
in: path
required: true
description: The kickoff ID of the execution to stop
schema:
type: string
format: uuid
example: "abcd1234-5678-90ef-ghij-klmnopqrstuv"
responses:
"200":
description: Successfully stopped the execution
content:
application/json:
schema:
$ref: "#/components/schemas/StopExecutionResponse"
example:
status: "stopped"
kickoffId: "abcd1234-5678-90ef-ghij-klmnopqrstuv"
"400":
description: Execution is already in a terminal state (SUCCESS, FAILURE, or REVOKED)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
detail: "Cannot stop execution. Current state: SUCCESS"
"401":
$ref: "#/components/responses/UnauthorizedError"
"404":
description: Kickoff ID not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: "Execution not found"
message: "No execution found with ID: abcd1234-5678-90ef-ghij-klmnopqrstuv"
"500":
$ref: "#/components/responses/ServerError"
/resume:
post:
summary: Resume Crew Execution with Human Feedback
@@ -559,19 +508,6 @@ components:
description: Time taken to execute this task in seconds
example: 45.2
StopExecutionResponse:
type: object
properties:
status:
type: string
enum: ["stopped"]
description: Indicates the execution was successfully stopped
example: "stopped"
kickoffId:
type: string
description: The kickoff ID of the stopped execution
example: "abcd1234-5678-90ef-ghij-klmnopqrstuv"
Error:
type: object
properties:

View File

@@ -120,46 +120,6 @@ paths:
'500':
$ref: '#/components/responses/ServerError'
/stop/{kickoff_id}:
post:
summary: 실행 중지
description: |
**📋 참조 예제만 제공** - *요청 형식을 보여줍니다. 실제 호출은 cURL 예제를 복사해 URL과 토큰을 교체하세요.*
실행 중인 crew 또는 flow 실행을 중지하거나 취소합니다. 실행이 활성 상태여야 합니다
(SUCCESS, FAILURE, REVOKED 상태가 아닌 경우).
operationId: stopCrewExecution
parameters:
- name: kickoff_id
in: path
required: true
schema:
type: string
format: uuid
responses:
'200':
description: 실행을 성공적으로 중지
content:
application/json:
schema:
$ref: '#/components/schemas/StopExecutionResponse'
'400':
description: 실행이 이미 종료 상태 (SUCCESS, FAILURE, REVOKED)
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
$ref: '#/components/responses/UnauthorizedError'
'404':
description: Kickoff ID를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
$ref: '#/components/responses/ServerError'
/resume:
post:
summary: Resume Crew Execution with Human Feedback
@@ -354,15 +314,6 @@ components:
execution_time:
type: number
StopExecutionResponse:
type: object
properties:
status:
type: string
enum: ["stopped"]
kickoffId:
type: string
Error:
type: object
properties:

View File

@@ -36,7 +36,6 @@ info:
1. **Descubra os inputs** usando `GET /inputs`
2. **Inicie a execução** usando `POST /kickoff`
3. **Monitore o progresso** usando `GET /{kickoff_id}/status`
4. **Pare a execução** (se necessário) usando `POST /stop/{kickoff_id}`
version: 1.0.0
contact:
name: CrewAI Suporte
@@ -157,46 +156,6 @@ paths:
"500":
$ref: "#/components/responses/ServerError"
/stop/{kickoff_id}:
post:
summary: Parar Execução da Crew
description: |
**📋 Exemplo de Referência** - *Mostra o formato da requisição. Para testar com sua crew real, copie o cURL e substitua URL + token.*
Para ou cancela uma execução de crew ou flow em andamento. A execução deve estar em um estado ativo
(não SUCCESS, FAILURE ou REVOKED).
operationId: stopCrewExecution
parameters:
- name: kickoff_id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Execução parada com sucesso
content:
application/json:
schema:
$ref: "#/components/schemas/StopExecutionResponse"
"400":
description: Execução já em estado terminal (SUCCESS, FAILURE ou REVOKED)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
$ref: "#/components/responses/UnauthorizedError"
"404":
description: Kickoff ID não encontrado
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/ServerError"
/resume:
post:
summary: Resume Crew Execution with Human Feedback
@@ -392,15 +351,6 @@ components:
execution_time:
type: number
StopExecutionResponse:
type: object
properties:
status:
type: string
enum: ["stopped"]
kickoffId:
type: string
Error:
type: object
properties:

View File

@@ -1,8 +0,0 @@
---
title: "POST /stop/{kickoff_id}"
description: "실행 중인 크루 실행 중지"
openapi: "/enterprise-api.ko.yaml POST /stop/{kickoff_id}"
mode: "wide"
---

View File

@@ -1,8 +0,0 @@
---
title: "POST /stop/{kickoff_id}"
description: "Parar uma execução de crew em andamento"
openapi: "/enterprise-api.pt-BR.yaml POST /stop/{kickoff_id}"
mode: "wide"
---

View File

@@ -7,6 +7,7 @@ various transport types, similar to OpenAI's Agents SDK.
from pydantic import BaseModel, Field
from crewai.mcp.filters import ToolFilter
from crewai.mcp.transports.stdio import DEFAULT_ALLOWED_COMMANDS
class MCPServerStdio(BaseModel):
@@ -44,6 +45,14 @@ class MCPServerStdio(BaseModel):
default=None,
description="Optional tool filter for filtering available tools.",
)
allowed_commands: frozenset[str] | None = Field(
default=DEFAULT_ALLOWED_COMMANDS,
description=(
"Optional frozenset of allowed command basenames for security validation. "
"Defaults to common runtimes (python, node, npx, uvx, uv, deno, docker). "
"Set to None to disable the allowlist check."
),
)
cache_tools_list: bool = Field(
default=False,
description="Whether to cache the tool list for faster subsequent access.",

View File

@@ -292,6 +292,7 @@ class MCPToolResolver:
command=mcp_config.command,
args=mcp_config.args,
env=mcp_config.env,
allowed_commands=mcp_config.allowed_commands,
)
server_name = f"{mcp_config.command}_{'_'.join(mcp_config.args)}"
elif isinstance(mcp_config, MCPServerHTTP):

View File

@@ -3,11 +3,12 @@
from crewai.mcp.transports.base import BaseTransport, TransportType
from crewai.mcp.transports.http import HTTPTransport
from crewai.mcp.transports.sse import SSETransport
from crewai.mcp.transports.stdio import StdioTransport
from crewai.mcp.transports.stdio import DEFAULT_ALLOWED_COMMANDS, StdioTransport
__all__ = [
"BaseTransport",
"DEFAULT_ALLOWED_COMMANDS",
"HTTPTransport",
"SSETransport",
"StdioTransport",

View File

@@ -9,6 +9,22 @@ from typing_extensions import Self
from crewai.mcp.transports.base import BaseTransport, TransportType
# Default allowlist for common MCP server runtimes.
# Covers the vast majority of MCP server launch commands.
# Pass ``allowed_commands=None`` to disable validation entirely.
DEFAULT_ALLOWED_COMMANDS: frozenset[str] = frozenset(
{
"python",
"python3",
"node",
"npx",
"uvx",
"uv",
"deno",
"docker",
}
)
class StdioTransport(BaseTransport):
"""Stdio transport for connecting to local MCP servers.
@@ -34,6 +50,7 @@ class StdioTransport(BaseTransport):
command: str,
args: list[str] | None = None,
env: dict[str, str] | None = None,
allowed_commands: frozenset[str] | None = DEFAULT_ALLOWED_COMMANDS,
**kwargs: Any,
) -> None:
"""Initialize stdio transport.
@@ -42,9 +59,26 @@ class StdioTransport(BaseTransport):
command: Command to execute (e.g., "python", "node", "npx").
args: Command arguments (e.g., ["server.py"] or ["-y", "@mcp/server"]).
env: Environment variables to pass to the process.
allowed_commands: Optional frozenset of allowed command basenames.
Defaults to ``DEFAULT_ALLOWED_COMMANDS`` which includes common
runtimes (python, node, npx, uvx, uv, deno, docker). Pass
``None`` to disable the check entirely.
**kwargs: Additional transport options.
"""
super().__init__(**kwargs)
if allowed_commands is not None:
base_command = os.path.basename(command)
# Strip extension for Windows compatibility (e.g., python.exe -> python)
base_command = os.path.splitext(base_command)[0]
if base_command not in allowed_commands:
raise ValueError(
f"Command '{command}' is not in the allowed commands list: "
f"{sorted(allowed_commands)}. "
f"To allow this command, add it to allowed_commands or pass "
f"allowed_commands=None to disable this check."
)
self.command = command
self.args = args or []
self.env = env or {}

View File

@@ -0,0 +1,28 @@
"""Tests for MCPServerStdio allowed_commands config integration."""
from crewai.mcp.config import MCPServerStdio
from crewai.mcp.transports.stdio import DEFAULT_ALLOWED_COMMANDS
class TestMCPServerStdioConfig:
"""Tests for the allowed_commands field on MCPServerStdio."""
def test_default_allowed_commands(self):
"""MCPServerStdio should default to DEFAULT_ALLOWED_COMMANDS."""
config = MCPServerStdio(command="python", args=["server.py"])
assert config.allowed_commands == DEFAULT_ALLOWED_COMMANDS
def test_custom_allowed_commands(self):
"""Users can override allowed_commands in config."""
custom = frozenset({"my-runtime"})
config = MCPServerStdio(
command="my-runtime", args=[], allowed_commands=custom
)
assert config.allowed_commands == custom
def test_none_allowed_commands(self):
"""Users can disable the allowlist via config."""
config = MCPServerStdio(
command="anything", args=[], allowed_commands=None
)
assert config.allowed_commands is None

View File

@@ -0,0 +1,93 @@
"""Tests for StdioTransport command allowlist validation."""
import pytest
from crewai.mcp.transports.stdio import DEFAULT_ALLOWED_COMMANDS, StdioTransport
class TestStdioTransportAllowlist:
"""Tests for the command allowlist feature."""
def test_default_allowed_commands_contains_common_runtimes(self):
"""DEFAULT_ALLOWED_COMMANDS should include all common MCP server runtimes."""
expected = {"python", "python3", "node", "npx", "uvx", "uv", "deno", "docker"}
assert expected == DEFAULT_ALLOWED_COMMANDS
def test_allowed_command_passes_validation(self):
"""Commands in the default allowlist should be accepted."""
for cmd in DEFAULT_ALLOWED_COMMANDS:
transport = StdioTransport(command=cmd, args=["server.py"])
assert transport.command == cmd
def test_allowed_command_with_full_path(self):
"""Full paths to allowed commands should pass (basename is checked)."""
transport = StdioTransport(command="/usr/bin/python3", args=["server.py"])
assert transport.command == "/usr/bin/python3"
def test_disallowed_command_raises_value_error(self):
"""Commands not in the allowlist should raise ValueError."""
with pytest.raises(ValueError, match="not in the allowed commands list"):
StdioTransport(command="malicious-binary", args=["--evil"])
def test_disallowed_command_with_full_path_raises(self):
"""Full paths to disallowed commands should also be rejected."""
with pytest.raises(ValueError, match="not in the allowed commands list"):
StdioTransport(command="/tmp/evil/script", args=[])
def test_allowed_commands_none_disables_validation(self):
"""Setting allowed_commands=None should disable the check entirely."""
transport = StdioTransport(
command="any-custom-binary",
args=["--flag"],
allowed_commands=None,
)
assert transport.command == "any-custom-binary"
def test_custom_allowlist(self):
"""Users should be able to pass a custom allowlist."""
custom = frozenset({"my-server", "python"})
# Allowed
transport = StdioTransport(
command="my-server", args=[], allowed_commands=custom
)
assert transport.command == "my-server"
# Not allowed
with pytest.raises(ValueError, match="not in the allowed commands list"):
StdioTransport(command="node", args=[], allowed_commands=custom)
def test_extended_allowlist(self):
"""Users should be able to extend the default allowlist."""
extended = DEFAULT_ALLOWED_COMMANDS | frozenset({"my-custom-runtime"})
transport = StdioTransport(
command="my-custom-runtime", args=[], allowed_commands=extended
)
assert transport.command == "my-custom-runtime"
# Original defaults still work
transport2 = StdioTransport(
command="python", args=["server.py"], allowed_commands=extended
)
assert transport2.command == "python"
def test_error_message_includes_sorted_allowed_commands(self):
"""The error message should list the allowed commands for discoverability."""
with pytest.raises(ValueError) as exc_info:
StdioTransport(command="bad-cmd", args=[])
error_msg = str(exc_info.value)
assert "bad-cmd" in error_msg
assert "allowed_commands=None" in error_msg
def test_args_and_env_still_work(self):
"""Existing args and env functionality should be unaffected."""
transport = StdioTransport(
command="python",
args=["server.py", "--port", "8080"],
env={"API_KEY": "test123"},
)
assert transport.command == "python"
assert transport.args == ["server.py", "--port", "8080"]
assert transport.env == {"API_KEY": "test123"}