mcp-yieldshell 0.2.0__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.
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/PKG-INFO +5 -1
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/README.md +4 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/pyproject.toml +1 -1
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/config.py +37 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/manager.py +80 -3
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/server.py +11 -1
- mcp_yieldshell-0.3.0/src/mcp_yieldshell/types.py +65 -0
- mcp_yieldshell-0.3.0/tests/test_config.py +220 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/test_integration.py +47 -44
- mcp_yieldshell-0.3.0/tests/test_side_effects.py +329 -0
- mcp_yieldshell-0.2.0/src/mcp_yieldshell/types.py +0 -32
- mcp_yieldshell-0.2.0/tests/test_config.py +0 -92
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/.gitignore +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/__init__.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/__main__.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/__init__.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/ring_buffer.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/spawn.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/security.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/__init__.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/test_ring_buffer.py +0 -0
- {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/test_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mcp-yieldshell
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A drop-in shell MCP that auto-yields long-running commands into managed background processes.
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: mcp<2,>=1.9.0
|
|
@@ -144,6 +144,8 @@ Execute a shell command. If the command runs longer than `yield_ms`, it yields a
|
|
|
144
144
|
|
|
145
145
|
* **Parameters**:
|
|
146
146
|
* `command` (string, **required**): The command string to execute in the shell.
|
|
147
|
+
* `side_effects` (array of string, **required**): The side-effect categories this command may plausibly have. Must contain at least one entry drawn from the enum below. Use `["NONE"]` for commands with no meaningful side effects. `NONE` is exclusive and must not be combined with any other category. The server rejects the call with `failed_to_start` if any declared category is configured as blocked.
|
|
148
|
+
* Allowed values: `NONE`, `MODIFIES_WORKSPACE_FILES`, `MODIFIES_PROTECTED_FILES`, `MODIFIES_OUTSIDE_WORKSPACE`, `DELETES_FILES`, `INSTALLS_DEPENDENCIES`, `CHANGES_SYSTEM_CONFIGURATION`, `BREAKS_OPERATING_SYSTEM`, `AFFECTS_PRODUCTION_SERVICES`, `STOPS_OR_RESTARTS_SERVICES`, `EXPOSES_SECRETS`, `CREATES_SECURITY_RISK`, `CHANGES_NETWORK_CONFIGURATION`, `MAKES_NETWORK_REQUESTS`, `RUNS_PRIVILEGED_COMMANDS`, `USES_DESTRUCTIVE_GIT_OPERATION`, `CONSUMES_SIGNIFICANT_RESOURCES`, `OTHER`, `UNKNOWN`.
|
|
147
149
|
* `cwd` (string, optional): Working directory for the command. Must be under allowed roots if `YIELDSHELL_ALLOWED_CWDS` is set. Defaults to `YIELDSHELL_DEFAULT_CWD`.
|
|
148
150
|
* `env` (object of string to string, optional): Additive environment variable overlay. Merged into the parent environment.
|
|
149
151
|
* `shell` (string, optional): Accepted but has no effect in v1. Commands always run via the platform's default shell.
|
|
@@ -253,12 +255,14 @@ Configure the server by setting these environment variables prior to launch:
|
|
|
253
255
|
| `YIELDSHELL_DENY_COMMAND_REGEX` | *(none)* | A regular expression pattern. Commands matching this pattern are blocked before starting. |
|
|
254
256
|
| `YIELDSHELL_ALLOW_COMMAND_REGEX` | *(none)* | A regular expression pattern. If set, only commands matching this pattern are permitted. |
|
|
255
257
|
| `YIELDSHELL_REDACT_ENV_REGEX` | `TOKEN\|KEY\|SECRET\|PASSWORD` | Regex to identify sensitive environment variable keys. Their values are redacted in stdout/stderr outputs. |
|
|
258
|
+
| `MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS` | `MODIFIES_PROTECTED_FILES,BREAKS_OPERATING_SYSTEM` | Comma-separated list of `side_effects` enum names the server should reject. Names are case-sensitive. Surrounding whitespace is trimmed and empty entries are ignored. Invalid names cause startup to fail. Set to `,` (or any value that resolves to no entries) to clear the default blocklist. |
|
|
256
259
|
|
|
257
260
|
---
|
|
258
261
|
|
|
259
262
|
## Security Notes
|
|
260
263
|
|
|
261
264
|
* **Arbitrary Code Execution**: This server executes shell commands on the host system. Always run the server inside a container, sandbox, or isolated development VM.
|
|
265
|
+
* **Side-Effect Declarations**: Every `exec` call must declare its plausible side-effect categories via `side_effects`. By default, `MODIFIES_PROTECTED_FILES` and `BREAKS_OPERATING_SYSTEM` are blocked. Operators can adjust the blocklist via `MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS`. This is an explicit risk signal — it is not a complete sandbox, and LLM under-declaration remains possible.
|
|
262
266
|
* **Path Validation**: CWD path verification uses absolute paths (`resolve()`), preventing path-traversal attacks (`../`) outside the allowed roots.
|
|
263
267
|
* **Additive Environments**: The `env` argument overlays existing env parameters. It merges with the parent process environment instead of completely replacing it, protecting critical OS vars.
|
|
264
268
|
* **Best-effort Redaction**: While values of variables matching `YIELDSHELL_REDACT_ENV_REGEX` are scrubbed from outputs, this is a best-effort system. Sensitive data printed through complex formats or argument lists might not be caught.
|
|
@@ -136,6 +136,8 @@ Execute a shell command. If the command runs longer than `yield_ms`, it yields a
|
|
|
136
136
|
|
|
137
137
|
* **Parameters**:
|
|
138
138
|
* `command` (string, **required**): The command string to execute in the shell.
|
|
139
|
+
* `side_effects` (array of string, **required**): The side-effect categories this command may plausibly have. Must contain at least one entry drawn from the enum below. Use `["NONE"]` for commands with no meaningful side effects. `NONE` is exclusive and must not be combined with any other category. The server rejects the call with `failed_to_start` if any declared category is configured as blocked.
|
|
140
|
+
* Allowed values: `NONE`, `MODIFIES_WORKSPACE_FILES`, `MODIFIES_PROTECTED_FILES`, `MODIFIES_OUTSIDE_WORKSPACE`, `DELETES_FILES`, `INSTALLS_DEPENDENCIES`, `CHANGES_SYSTEM_CONFIGURATION`, `BREAKS_OPERATING_SYSTEM`, `AFFECTS_PRODUCTION_SERVICES`, `STOPS_OR_RESTARTS_SERVICES`, `EXPOSES_SECRETS`, `CREATES_SECURITY_RISK`, `CHANGES_NETWORK_CONFIGURATION`, `MAKES_NETWORK_REQUESTS`, `RUNS_PRIVILEGED_COMMANDS`, `USES_DESTRUCTIVE_GIT_OPERATION`, `CONSUMES_SIGNIFICANT_RESOURCES`, `OTHER`, `UNKNOWN`.
|
|
139
141
|
* `cwd` (string, optional): Working directory for the command. Must be under allowed roots if `YIELDSHELL_ALLOWED_CWDS` is set. Defaults to `YIELDSHELL_DEFAULT_CWD`.
|
|
140
142
|
* `env` (object of string to string, optional): Additive environment variable overlay. Merged into the parent environment.
|
|
141
143
|
* `shell` (string, optional): Accepted but has no effect in v1. Commands always run via the platform's default shell.
|
|
@@ -245,12 +247,14 @@ Configure the server by setting these environment variables prior to launch:
|
|
|
245
247
|
| `YIELDSHELL_DENY_COMMAND_REGEX` | *(none)* | A regular expression pattern. Commands matching this pattern are blocked before starting. |
|
|
246
248
|
| `YIELDSHELL_ALLOW_COMMAND_REGEX` | *(none)* | A regular expression pattern. If set, only commands matching this pattern are permitted. |
|
|
247
249
|
| `YIELDSHELL_REDACT_ENV_REGEX` | `TOKEN\|KEY\|SECRET\|PASSWORD` | Regex to identify sensitive environment variable keys. Their values are redacted in stdout/stderr outputs. |
|
|
250
|
+
| `MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS` | `MODIFIES_PROTECTED_FILES,BREAKS_OPERATING_SYSTEM` | Comma-separated list of `side_effects` enum names the server should reject. Names are case-sensitive. Surrounding whitespace is trimmed and empty entries are ignored. Invalid names cause startup to fail. Set to `,` (or any value that resolves to no entries) to clear the default blocklist. |
|
|
248
251
|
|
|
249
252
|
---
|
|
250
253
|
|
|
251
254
|
## Security Notes
|
|
252
255
|
|
|
253
256
|
* **Arbitrary Code Execution**: This server executes shell commands on the host system. Always run the server inside a container, sandbox, or isolated development VM.
|
|
257
|
+
* **Side-Effect Declarations**: Every `exec` call must declare its plausible side-effect categories via `side_effects`. By default, `MODIFIES_PROTECTED_FILES` and `BREAKS_OPERATING_SYSTEM` are blocked. Operators can adjust the blocklist via `MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS`. This is an explicit risk signal — it is not a complete sandbox, and LLM under-declaration remains possible.
|
|
254
258
|
* **Path Validation**: CWD path verification uses absolute paths (`resolve()`), preventing path-traversal attacks (`../`) outside the allowed roots.
|
|
255
259
|
* **Additive Environments**: The `env` argument overlays existing env parameters. It merges with the parent process environment instead of completely replacing it, protecting critical OS vars.
|
|
256
260
|
* **Best-effort Redaction**: While values of variables matching `YIELDSHELL_REDACT_ENV_REGEX` are scrubbed from outputs, this is a best-effort system. Sensitive data printed through complex formats or argument lists might not be caught.
|
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
|
|
8
|
+
from .types import DEFAULT_BLOCKED_SIDE_EFFECTS, SideEffect
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
class Config:
|
|
10
12
|
def __init__(self) -> None:
|
|
@@ -37,6 +39,9 @@ class Config:
|
|
|
37
39
|
os.environ.get("YIELDSHELL_REDACT_ENV_REGEX", ""),
|
|
38
40
|
r"TOKEN|KEY|SECRET|PASSWORD",
|
|
39
41
|
)
|
|
42
|
+
self.blocked_side_effects: frozenset[SideEffect] = _parse_blocked_side_effects(
|
|
43
|
+
os.environ.get("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "")
|
|
44
|
+
)
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
def _parse_pathsep(value: str) -> list[str]:
|
|
@@ -64,3 +69,35 @@ def _parse_regex_required(value: str, default: str) -> re.Pattern[str]:
|
|
|
64
69
|
if not value:
|
|
65
70
|
return re.compile(default)
|
|
66
71
|
return re.compile(value)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_blocked_side_effects(value: str) -> frozenset[SideEffect]:
|
|
75
|
+
"""Parse ``MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS`` into a frozenset.
|
|
76
|
+
|
|
77
|
+
Empty entries are ignored. Surrounding whitespace is trimmed. Names are
|
|
78
|
+
case-sensitive. Invalid names raise ``ValueError`` with a clear message.
|
|
79
|
+
"""
|
|
80
|
+
if value is None or not value.strip():
|
|
81
|
+
return DEFAULT_BLOCKED_SIDE_EFFECTS
|
|
82
|
+
|
|
83
|
+
valid_names = {member.name for member in SideEffect}
|
|
84
|
+
blocked: set[SideEffect] = set()
|
|
85
|
+
invalid: list[str] = []
|
|
86
|
+
for entry in value.split(","):
|
|
87
|
+
name = entry.strip()
|
|
88
|
+
if not name:
|
|
89
|
+
continue
|
|
90
|
+
if name in valid_names:
|
|
91
|
+
blocked.add(SideEffect[name])
|
|
92
|
+
else:
|
|
93
|
+
invalid.append(name)
|
|
94
|
+
|
|
95
|
+
if invalid:
|
|
96
|
+
names_list = ", ".join(repr(n) for n in invalid)
|
|
97
|
+
valid_list = ", ".join(sorted(valid_names))
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS contains invalid value(s): "
|
|
100
|
+
f"{names_list}. Valid values (case-sensitive): {valid_list}."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return frozenset(blocked)
|
|
@@ -7,11 +7,11 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
import time
|
|
9
9
|
import uuid
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, Iterable
|
|
11
11
|
|
|
12
12
|
from ..config import Config
|
|
13
13
|
from ..security import redact_text
|
|
14
|
-
from ..types import ProcessInfo, ProcessStatus
|
|
14
|
+
from ..types import ProcessInfo, ProcessStatus, SideEffect
|
|
15
15
|
from .ring_buffer import RingBuffer
|
|
16
16
|
from .spawn import kill_process, spawn_process, terminate_process
|
|
17
17
|
|
|
@@ -81,9 +81,74 @@ class ProcessManager:
|
|
|
81
81
|
return self._config.default_timeout_ms
|
|
82
82
|
return max(0, requested)
|
|
83
83
|
|
|
84
|
+
def _normalize_side_effects(
|
|
85
|
+
self, side_effects: Iterable[Any] | None
|
|
86
|
+
) -> list[SideEffect] | None:
|
|
87
|
+
"""Coerce ``side_effects`` entries into ``SideEffect`` enum members.
|
|
88
|
+
|
|
89
|
+
Accepts ``SideEffect`` instances or strings. Returns ``None`` when
|
|
90
|
+
``side_effects`` itself is ``None``. Raises ``TypeError`` when an
|
|
91
|
+
entry is not a string and not a ``SideEffect``.
|
|
92
|
+
"""
|
|
93
|
+
if side_effects is None:
|
|
94
|
+
return None
|
|
95
|
+
normalized: list[SideEffect] = []
|
|
96
|
+
for item in side_effects:
|
|
97
|
+
if isinstance(item, SideEffect):
|
|
98
|
+
normalized.append(item)
|
|
99
|
+
elif isinstance(item, str):
|
|
100
|
+
normalized.append(SideEffect(item))
|
|
101
|
+
else:
|
|
102
|
+
raise TypeError(
|
|
103
|
+
f"side_effects entries must be SideEffect or str, got {type(item).__name__}"
|
|
104
|
+
)
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
def _check_side_effects(
|
|
108
|
+
self, side_effects: Iterable[SideEffect] | None
|
|
109
|
+
) -> str | None:
|
|
110
|
+
"""Validate the side-effect declaration.
|
|
111
|
+
|
|
112
|
+
Returns an error message if the declaration is invalid (empty list,
|
|
113
|
+
``NONE`` combined with another value, or any declared category is
|
|
114
|
+
configured as blocked). Returns ``None`` when the declaration is
|
|
115
|
+
allowed and execution can proceed.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
declared = self._normalize_side_effects(side_effects)
|
|
119
|
+
except ValueError as exc:
|
|
120
|
+
return f"Invalid side_effects: {exc}"
|
|
121
|
+
except TypeError as exc:
|
|
122
|
+
return str(exc)
|
|
123
|
+
if declared is None:
|
|
124
|
+
return "side_effects is required"
|
|
125
|
+
if not declared:
|
|
126
|
+
return (
|
|
127
|
+
'side_effects must not be empty; pass ["NONE"] for commands '
|
|
128
|
+
"with no side effects"
|
|
129
|
+
)
|
|
130
|
+
has_none = SideEffect.NONE in declared
|
|
131
|
+
non_none = [s for s in declared if s is not SideEffect.NONE]
|
|
132
|
+
if has_none and non_none:
|
|
133
|
+
return (
|
|
134
|
+
"side_effects=NONE is exclusive and cannot be combined with other "
|
|
135
|
+
f"categories: {[s.name for s in non_none]}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
blocked = self._config.blocked_side_effects
|
|
139
|
+
hits = [s for s in declared if s in blocked]
|
|
140
|
+
if hits:
|
|
141
|
+
names = ", ".join(s.name for s in hits)
|
|
142
|
+
return (
|
|
143
|
+
f"Side-effect category blocked by policy: {names}. "
|
|
144
|
+
"Re-declare with an allowed category or update operator policy."
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
|
|
84
148
|
async def exec_command(
|
|
85
149
|
self,
|
|
86
150
|
command: str,
|
|
151
|
+
side_effects: list[SideEffect] | Iterable[SideEffect],
|
|
87
152
|
cwd: str | None = None,
|
|
88
153
|
env_overlay: dict[str, str] | None = None,
|
|
89
154
|
shell: str | None = None,
|
|
@@ -93,9 +158,21 @@ class ProcessManager:
|
|
|
93
158
|
timeout_ms: int | None = None,
|
|
94
159
|
max_output_bytes: int | None = None,
|
|
95
160
|
) -> dict[str, Any]:
|
|
96
|
-
"""Execute a shell command with auto-yield behavior.
|
|
161
|
+
"""Execute a shell command with auto-yield behavior.
|
|
162
|
+
|
|
163
|
+
The ``side_effects`` declaration is validated before any cwd or command
|
|
164
|
+
policy check, before process-limit checks, before environment overlay
|
|
165
|
+
construction, and before subprocess spawn.
|
|
166
|
+
"""
|
|
97
167
|
from ..security import build_env, resolve_cwd, validate_command
|
|
98
168
|
|
|
169
|
+
# Validate side-effect declaration first so blocked categories never
|
|
170
|
+
# reach cwd resolution, command policy, process limits, env overlay
|
|
171
|
+
# building, or process spawn.
|
|
172
|
+
side_effects_error = self._check_side_effects(side_effects)
|
|
173
|
+
if side_effects_error:
|
|
174
|
+
return {"status": "failed_to_start", "error": side_effects_error}
|
|
175
|
+
|
|
99
176
|
# Validate command policy
|
|
100
177
|
cmd_error = validate_command(self._config, command)
|
|
101
178
|
if cmd_error:
|
|
@@ -6,6 +6,7 @@ from mcp.server.fastmcp import FastMCP
|
|
|
6
6
|
|
|
7
7
|
from .config import Config
|
|
8
8
|
from .process.manager import ProcessManager
|
|
9
|
+
from .types import SideEffect
|
|
9
10
|
|
|
10
11
|
mcp = FastMCP("YieldShell MCP")
|
|
11
12
|
|
|
@@ -22,6 +23,7 @@ def _get_manager() -> ProcessManager:
|
|
|
22
23
|
@mcp.tool()
|
|
23
24
|
async def exec(
|
|
24
25
|
command: str,
|
|
26
|
+
side_effects: list[SideEffect],
|
|
25
27
|
cwd: str | None = None,
|
|
26
28
|
env: dict[str, str] | None = None,
|
|
27
29
|
shell: str | None = None,
|
|
@@ -31,9 +33,17 @@ async def exec(
|
|
|
31
33
|
timeout_ms: int | None = None,
|
|
32
34
|
max_output_bytes: int | None = None,
|
|
33
35
|
) -> dict:
|
|
34
|
-
"""Execute a shell command with auto-yield for long-running processes.
|
|
36
|
+
"""Execute a shell command with auto-yield for long-running processes.
|
|
37
|
+
|
|
38
|
+
Callers must declare every plausible side effect category in ``side_effects``.
|
|
39
|
+
If no meaningful side effect is expected, pass ``[SideEffect.NONE]`` or
|
|
40
|
+
``["NONE"]``. ``NONE`` is exclusive and must not be combined with any other
|
|
41
|
+
category. The server rejects the command before spawning a process if any
|
|
42
|
+
declared category is configured as blocked.
|
|
43
|
+
"""
|
|
35
44
|
return await _get_manager().exec_command(
|
|
36
45
|
command=command,
|
|
46
|
+
side_effects=side_effects,
|
|
37
47
|
cwd=cwd,
|
|
38
48
|
env_overlay=env,
|
|
39
49
|
shell=shell,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Types for process status, tool response shapes, and side-effect taxonomy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ProcessStatus(str, Enum):
|
|
10
|
+
RUNNING = "running"
|
|
11
|
+
COMPLETED = "completed"
|
|
12
|
+
STOPPED = "stopped"
|
|
13
|
+
TIMED_OUT = "timed_out"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SideEffect(str, Enum):
|
|
18
|
+
"""Taxonomy of side effects a shell command may plausibly have.
|
|
19
|
+
|
|
20
|
+
Callers of ``exec_command`` must declare every category that plausibly
|
|
21
|
+
applies. If no meaningful side effect is expected, callers must pass
|
|
22
|
+
``[SideEffect.NONE]``. ``NONE`` is exclusive and must not be combined
|
|
23
|
+
with any other category.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
NONE = "NONE"
|
|
27
|
+
MODIFIES_WORKSPACE_FILES = "MODIFIES_WORKSPACE_FILES"
|
|
28
|
+
MODIFIES_PROTECTED_FILES = "MODIFIES_PROTECTED_FILES"
|
|
29
|
+
MODIFIES_OUTSIDE_WORKSPACE = "MODIFIES_OUTSIDE_WORKSPACE"
|
|
30
|
+
DELETES_FILES = "DELETES_FILES"
|
|
31
|
+
INSTALLS_DEPENDENCIES = "INSTALLS_DEPENDENCIES"
|
|
32
|
+
CHANGES_SYSTEM_CONFIGURATION = "CHANGES_SYSTEM_CONFIGURATION"
|
|
33
|
+
BREAKS_OPERATING_SYSTEM = "BREAKS_OPERATING_SYSTEM"
|
|
34
|
+
AFFECTS_PRODUCTION_SERVICES = "AFFECTS_PRODUCTION_SERVICES"
|
|
35
|
+
STOPS_OR_RESTARTS_SERVICES = "STOPS_OR_RESTARTS_SERVICES"
|
|
36
|
+
EXPOSES_SECRETS = "EXPOSES_SECRETS"
|
|
37
|
+
CREATES_SECURITY_RISK = "CREATES_SECURITY_RISK"
|
|
38
|
+
CHANGES_NETWORK_CONFIGURATION = "CHANGES_NETWORK_CONFIGURATION"
|
|
39
|
+
MAKES_NETWORK_REQUESTS = "MAKES_NETWORK_REQUESTS"
|
|
40
|
+
RUNS_PRIVILEGED_COMMANDS = "RUNS_PRIVILEGED_COMMANDS"
|
|
41
|
+
USES_DESTRUCTIVE_GIT_OPERATION = "USES_DESTRUCTIVE_GIT_OPERATION"
|
|
42
|
+
CONSUMES_SIGNIFICANT_RESOURCES = "CONSUMES_SIGNIFICANT_RESOURCES"
|
|
43
|
+
OTHER = "OTHER"
|
|
44
|
+
UNKNOWN = "UNKNOWN"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
DEFAULT_BLOCKED_SIDE_EFFECTS: frozenset[SideEffect] = frozenset(
|
|
48
|
+
{SideEffect.MODIFIES_PROTECTED_FILES, SideEffect.BREAKS_OPERATING_SYSTEM}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ProcessInfo:
|
|
54
|
+
process_id: str
|
|
55
|
+
pid: int | None
|
|
56
|
+
command: str
|
|
57
|
+
cwd: str
|
|
58
|
+
name: str | None
|
|
59
|
+
status: ProcessStatus
|
|
60
|
+
exit_code: int | None = None
|
|
61
|
+
signal: str | None = None
|
|
62
|
+
started_at: float = 0.0
|
|
63
|
+
ended_at: float | None = None
|
|
64
|
+
duration_ms: float = 0.0
|
|
65
|
+
start_monotonic: float = 0.0
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Unit tests for configuration parsing."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from mcp_yieldshell.config import Config
|
|
8
|
+
from mcp_yieldshell.types import DEFAULT_BLOCKED_SIDE_EFFECTS, SideEffect
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestConfigDefaults:
|
|
12
|
+
def test_default_cwd(self):
|
|
13
|
+
config = Config()
|
|
14
|
+
assert config.default_cwd == os.getcwd()
|
|
15
|
+
|
|
16
|
+
def test_default_max_output_bytes(self):
|
|
17
|
+
config = Config()
|
|
18
|
+
assert config.max_output_bytes == 20000
|
|
19
|
+
|
|
20
|
+
def test_default_max_processes(self):
|
|
21
|
+
config = Config()
|
|
22
|
+
assert config.max_processes == 50
|
|
23
|
+
|
|
24
|
+
def test_default_yield_ms(self):
|
|
25
|
+
config = Config()
|
|
26
|
+
assert config.default_yield_ms == 5000
|
|
27
|
+
|
|
28
|
+
def test_default_max_yield_ms(self):
|
|
29
|
+
config = Config()
|
|
30
|
+
assert config.max_yield_ms == 300000
|
|
31
|
+
|
|
32
|
+
def test_default_timeout_ms(self):
|
|
33
|
+
config = Config()
|
|
34
|
+
assert config.default_timeout_ms == 0
|
|
35
|
+
|
|
36
|
+
def test_empty_allowed_cwds(self):
|
|
37
|
+
config = Config()
|
|
38
|
+
assert config.allowed_cwd_roots == []
|
|
39
|
+
|
|
40
|
+
def test_none_deny_regex(self):
|
|
41
|
+
config = Config()
|
|
42
|
+
assert config.deny_command_regex is None
|
|
43
|
+
|
|
44
|
+
def test_none_allow_regex(self):
|
|
45
|
+
config = Config()
|
|
46
|
+
assert config.allow_command_regex is None
|
|
47
|
+
|
|
48
|
+
def test_default_redact_regex(self):
|
|
49
|
+
config = Config()
|
|
50
|
+
assert config.redact_env_regex is not None
|
|
51
|
+
assert config.redact_env_regex.search("MY_TOKEN")
|
|
52
|
+
assert config.redact_env_regex.search("API_KEY")
|
|
53
|
+
assert config.redact_env_regex.search("MY_SECRET")
|
|
54
|
+
assert config.redact_env_regex.search("DB_PASSWORD")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestConfigFromEnv:
|
|
58
|
+
def test_custom_max_output_bytes(self, monkeypatch):
|
|
59
|
+
monkeypatch.setenv("YIELDSHELL_MAX_OUTPUT_BYTES", "5000")
|
|
60
|
+
config = Config()
|
|
61
|
+
assert config.max_output_bytes == 5000
|
|
62
|
+
|
|
63
|
+
def test_custom_max_processes(self, monkeypatch):
|
|
64
|
+
monkeypatch.setenv("YIELDSHELL_MAX_PROCESSES", "10")
|
|
65
|
+
config = Config()
|
|
66
|
+
assert config.max_processes == 10
|
|
67
|
+
|
|
68
|
+
def test_custom_default_yield_ms(self, monkeypatch):
|
|
69
|
+
monkeypatch.setenv("YIELDSHELL_DEFAULT_YIELD_MS", "2000")
|
|
70
|
+
config = Config()
|
|
71
|
+
assert config.default_yield_ms == 2000
|
|
72
|
+
|
|
73
|
+
def test_deny_command_regex(self, monkeypatch):
|
|
74
|
+
monkeypatch.setenv("YIELDSHELL_DENY_COMMAND_REGEX", r"rm\s+-rf")
|
|
75
|
+
config = Config()
|
|
76
|
+
assert config.deny_command_regex is not None
|
|
77
|
+
assert config.deny_command_regex.search("rm -rf /")
|
|
78
|
+
assert not config.deny_command_regex.search("ls -la")
|
|
79
|
+
|
|
80
|
+
def test_allow_command_regex(self, monkeypatch):
|
|
81
|
+
monkeypatch.setenv("YIELDSHELL_ALLOW_COMMAND_REGEX", r"^git\s+")
|
|
82
|
+
config = Config()
|
|
83
|
+
assert config.allow_command_regex is not None
|
|
84
|
+
assert config.allow_command_regex.search("git status")
|
|
85
|
+
assert not config.allow_command_regex.search("ls -la")
|
|
86
|
+
|
|
87
|
+
def test_allowed_cwds(self, monkeypatch):
|
|
88
|
+
monkeypatch.setenv("YIELDSHELL_ALLOWED_CWDS", "/tmp:/home")
|
|
89
|
+
config = Config()
|
|
90
|
+
assert len(config.allowed_cwd_roots) == 2
|
|
91
|
+
|
|
92
|
+
def test_invalid_int_uses_default(self, monkeypatch):
|
|
93
|
+
monkeypatch.setenv("YIELDSHELL_MAX_PROCESSES", "abc")
|
|
94
|
+
config = Config()
|
|
95
|
+
assert config.max_processes == 50
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestBlockedSideEffectsDefaults:
|
|
99
|
+
def test_unset_uses_default_blocked_set(self, monkeypatch):
|
|
100
|
+
monkeypatch.delenv("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", raising=False)
|
|
101
|
+
config = Config()
|
|
102
|
+
assert config.blocked_side_effects == DEFAULT_BLOCKED_SIDE_EFFECTS
|
|
103
|
+
|
|
104
|
+
def test_unset_default_blocks_modifies_protected_files(self, monkeypatch):
|
|
105
|
+
monkeypatch.delenv("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", raising=False)
|
|
106
|
+
config = Config()
|
|
107
|
+
assert SideEffect.MODIFIES_PROTECTED_FILES in config.blocked_side_effects
|
|
108
|
+
|
|
109
|
+
def test_unset_default_blocks_breaks_operating_system(self, monkeypatch):
|
|
110
|
+
monkeypatch.delenv("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", raising=False)
|
|
111
|
+
config = Config()
|
|
112
|
+
assert SideEffect.BREAKS_OPERATING_SYSTEM in config.blocked_side_effects
|
|
113
|
+
|
|
114
|
+
def test_empty_string_uses_default_blocked_set(self, monkeypatch):
|
|
115
|
+
monkeypatch.setenv("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "")
|
|
116
|
+
config = Config()
|
|
117
|
+
assert config.blocked_side_effects == DEFAULT_BLOCKED_SIDE_EFFECTS
|
|
118
|
+
|
|
119
|
+
def test_whitespace_only_uses_default_blocked_set(self, monkeypatch):
|
|
120
|
+
monkeypatch.setenv("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", " ")
|
|
121
|
+
config = Config()
|
|
122
|
+
assert config.blocked_side_effects == DEFAULT_BLOCKED_SIDE_EFFECTS
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestBlockedSideEffectsFromEnv:
|
|
126
|
+
def test_single_value_parsed(self, monkeypatch):
|
|
127
|
+
monkeypatch.setenv(
|
|
128
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "DELETES_FILES"
|
|
129
|
+
)
|
|
130
|
+
config = Config()
|
|
131
|
+
assert config.blocked_side_effects == frozenset({SideEffect.DELETES_FILES})
|
|
132
|
+
|
|
133
|
+
def test_multiple_values_parsed(self, monkeypatch):
|
|
134
|
+
monkeypatch.setenv(
|
|
135
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS",
|
|
136
|
+
"DELETES_FILES,MAKES_NETWORK_REQUESTS,INSTALLS_DEPENDENCIES",
|
|
137
|
+
)
|
|
138
|
+
config = Config()
|
|
139
|
+
assert config.blocked_side_effects == frozenset(
|
|
140
|
+
{
|
|
141
|
+
SideEffect.DELETES_FILES,
|
|
142
|
+
SideEffect.MAKES_NETWORK_REQUESTS,
|
|
143
|
+
SideEffect.INSTALLS_DEPENDENCIES,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def test_whitespace_trimmed(self, monkeypatch):
|
|
148
|
+
monkeypatch.setenv(
|
|
149
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS",
|
|
150
|
+
" DELETES_FILES ,\t MAKES_NETWORK_REQUESTS \t",
|
|
151
|
+
)
|
|
152
|
+
config = Config()
|
|
153
|
+
assert config.blocked_side_effects == frozenset(
|
|
154
|
+
{SideEffect.DELETES_FILES, SideEffect.MAKES_NETWORK_REQUESTS}
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_empty_entries_ignored(self, monkeypatch):
|
|
158
|
+
monkeypatch.setenv(
|
|
159
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS",
|
|
160
|
+
",,DELETES_FILES, ,,",
|
|
161
|
+
)
|
|
162
|
+
config = Config()
|
|
163
|
+
assert config.blocked_side_effects == frozenset({SideEffect.DELETES_FILES})
|
|
164
|
+
|
|
165
|
+
def test_can_clear_blocked_categories(self, monkeypatch):
|
|
166
|
+
monkeypatch.setenv("MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", ",")
|
|
167
|
+
config = Config()
|
|
168
|
+
assert config.blocked_side_effects == frozenset()
|
|
169
|
+
|
|
170
|
+
def test_override_default_to_unblock_modifies_protected_files(self, monkeypatch):
|
|
171
|
+
monkeypatch.setenv(
|
|
172
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "BREAKS_OPERATING_SYSTEM"
|
|
173
|
+
)
|
|
174
|
+
config = Config()
|
|
175
|
+
assert config.blocked_side_effects == frozenset(
|
|
176
|
+
{SideEffect.BREAKS_OPERATING_SYSTEM}
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestBlockedSideEffectsInvalid:
|
|
181
|
+
def test_lowercase_value_fails_clearly(self, monkeypatch):
|
|
182
|
+
monkeypatch.setenv(
|
|
183
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "modifies_protected_files"
|
|
184
|
+
)
|
|
185
|
+
with pytest.raises(ValueError) as excinfo:
|
|
186
|
+
Config()
|
|
187
|
+
message = str(excinfo.value)
|
|
188
|
+
assert "modifies_protected_files" in message
|
|
189
|
+
assert "MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS" in message
|
|
190
|
+
assert "case-sensitive" in message
|
|
191
|
+
|
|
192
|
+
def test_typo_value_fails_clearly(self, monkeypatch):
|
|
193
|
+
monkeypatch.setenv(
|
|
194
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "DELETES_FILE"
|
|
195
|
+
)
|
|
196
|
+
with pytest.raises(ValueError) as excinfo:
|
|
197
|
+
Config()
|
|
198
|
+
message = str(excinfo.value)
|
|
199
|
+
assert "DELETES_FILE" in message
|
|
200
|
+
assert "DELETES_FILES" in message # listed in the valid set
|
|
201
|
+
|
|
202
|
+
def test_completely_unknown_value_fails_clearly(self, monkeypatch):
|
|
203
|
+
monkeypatch.setenv(
|
|
204
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS", "TELEPORT_COWS"
|
|
205
|
+
)
|
|
206
|
+
with pytest.raises(ValueError) as excinfo:
|
|
207
|
+
Config()
|
|
208
|
+
message = str(excinfo.value)
|
|
209
|
+
assert "TELEPORT_COWS" in message
|
|
210
|
+
assert "Valid values" in message
|
|
211
|
+
|
|
212
|
+
def test_mixed_valid_and_invalid_fails_clearly(self, monkeypatch):
|
|
213
|
+
monkeypatch.setenv(
|
|
214
|
+
"MCP_YIELDSHELL_BLOCKED_SIDE_EFFECTS",
|
|
215
|
+
"DELETES_FILES,TELEPORT_COWS,MAKES_NETWORK_REQUESTS",
|
|
216
|
+
)
|
|
217
|
+
with pytest.raises(ValueError) as excinfo:
|
|
218
|
+
Config()
|
|
219
|
+
message = str(excinfo.value)
|
|
220
|
+
assert "TELEPORT_COWS" in message
|