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.
Files changed (22) hide show
  1. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/PKG-INFO +5 -1
  2. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/README.md +4 -0
  3. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/pyproject.toml +1 -1
  4. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/config.py +37 -0
  5. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/manager.py +80 -3
  6. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/server.py +11 -1
  7. mcp_yieldshell-0.3.0/src/mcp_yieldshell/types.py +65 -0
  8. mcp_yieldshell-0.3.0/tests/test_config.py +220 -0
  9. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/test_integration.py +47 -44
  10. mcp_yieldshell-0.3.0/tests/test_side_effects.py +329 -0
  11. mcp_yieldshell-0.2.0/src/mcp_yieldshell/types.py +0 -32
  12. mcp_yieldshell-0.2.0/tests/test_config.py +0 -92
  13. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/.gitignore +0 -0
  14. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/__init__.py +0 -0
  15. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/__main__.py +0 -0
  16. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/__init__.py +0 -0
  17. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/ring_buffer.py +0 -0
  18. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/process/spawn.py +0 -0
  19. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/src/mcp_yieldshell/security.py +0 -0
  20. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/__init__.py +0 -0
  21. {mcp_yieldshell-0.2.0 → mcp_yieldshell-0.3.0}/tests/test_ring_buffer.py +0 -0
  22. {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.2.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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mcp-yieldshell"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "A drop-in shell MCP that auto-yields long-running commands into managed background processes."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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