server-management-lib 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,200 @@
1
+ """
2
+ Local command executor.
3
+
4
+ Runs the same validated operation catalog as the SSH executor, but on the host
5
+ where the MCP server runs. Commands are executed as argument vectors (never
6
+ through a shell), and file access is confined to the configured service base.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import shlex
12
+ import stat
13
+ import time
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from .base import (
18
+ ExecutorBase,
19
+ RemoteDirEntry,
20
+ RemoteFileError,
21
+ RemotePathError,
22
+ is_within,
23
+ )
24
+ from .output import sanitize_output
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ SHELL_OPERATORS = {
29
+ "&",
30
+ "&&",
31
+ ";",
32
+ "|",
33
+ "||",
34
+ ">",
35
+ ">>",
36
+ "<",
37
+ "<<",
38
+ "2>",
39
+ "2>&1",
40
+ }
41
+
42
+
43
+ def _read_regular_file(path: Path, limit: int) -> tuple[bytes, int]:
44
+ """Read a regular file without following special files (FIFOs, devices)."""
45
+ info = path.stat()
46
+ if not stat.S_ISREG(info.st_mode):
47
+ raise RemoteFileError(f"Not a regular file: {path}")
48
+ with path.open("rb") as handle:
49
+ return handle.read(limit + 1), info.st_size
50
+
51
+
52
+ class LocalExecutor(ExecutorBase):
53
+ """Execute validated commands and file operations on the local host."""
54
+
55
+ async def connect(self) -> None:
56
+ """No-op; kept for interface parity with the SSH executor."""
57
+
58
+ async def disconnect(self) -> None:
59
+ """No-op; kept for interface parity with the SSH executor."""
60
+
61
+ async def execute_safe_command(self, command: str, timeout: int = 30) -> str:
62
+ """Execute a validated command as an argv vector (no shell)."""
63
+ if not self.security.is_command_safe(command):
64
+ logger.error("Attempted to execute unsafe command: %s", command)
65
+ self.audit.log("command_blocked", command=command, executor="local")
66
+ return "❌ Security violation: Command blocked by security policy"
67
+
68
+ argv = shlex.split(command)
69
+ if not argv:
70
+ return "❌ Empty command"
71
+
72
+ if any(token in SHELL_OPERATORS for token in argv):
73
+ self.audit.log("command_blocked", command=command, reason="shell_operator")
74
+ return (
75
+ "❌ Shell operators are not supported by the local executor; "
76
+ "the command must be a plain argv vector."
77
+ )
78
+
79
+ started = time.monotonic()
80
+ try:
81
+ process = await asyncio.create_subprocess_exec(
82
+ *argv,
83
+ stdout=asyncio.subprocess.PIPE,
84
+ stderr=asyncio.subprocess.PIPE,
85
+ )
86
+ except FileNotFoundError:
87
+ self.audit.log("command_error", command=command, error="not_found")
88
+ return f"❌ Command not found: {argv[0]}"
89
+ except OSError as e:
90
+ self.audit.log("command_error", command=command, error=str(e))
91
+ return f"❌ Error executing command: {e}"
92
+
93
+ try:
94
+ stdout, stderr = await asyncio.wait_for(
95
+ process.communicate(), timeout=timeout
96
+ )
97
+ except TimeoutError:
98
+ process.kill()
99
+ await process.wait()
100
+ self.audit.log("command_timeout", command=command, timeout=timeout)
101
+ return f"❌ Command timed out after {timeout}s"
102
+
103
+ output = stdout.decode("utf-8", errors="replace")
104
+ error_output = stderr.decode("utf-8", errors="replace")
105
+ if error_output:
106
+ output += ("\n--- STDERR ---\n" if output else "") + error_output
107
+ if process.returncode:
108
+ output += f"\n\n⚠️ Exit code: {process.returncode}"
109
+
110
+ sanitized = sanitize_output(
111
+ output, self.max_output_bytes, label="command output"
112
+ )
113
+ self.audit.log(
114
+ "command",
115
+ command=command,
116
+ executor="local",
117
+ exit_status=process.returncode,
118
+ duration_ms=round((time.monotonic() - started) * 1000, 1),
119
+ output_bytes=len(output.encode("utf-8")),
120
+ truncated=sanitized != output,
121
+ )
122
+ return sanitized
123
+
124
+ async def check_service_exists(self, service: str) -> bool:
125
+ """Check the local service directory."""
126
+ return (Path(self.services_base_path) / service).is_dir()
127
+
128
+ async def read_remote_file(
129
+ self,
130
+ path: str,
131
+ *,
132
+ allowed_base: str | None = None,
133
+ max_bytes: int | None = None,
134
+ ) -> str:
135
+ """Read a local file with the same confinement rules as SFTP reads."""
136
+ limit = max_bytes or self.max_output_bytes
137
+ real_path = Path(path).resolve()
138
+ real_base = Path(allowed_base).resolve() if allowed_base else None
139
+ if real_base is not None and not is_within(str(real_path), str(real_base)):
140
+ self.audit.log("file_read_blocked", path=path, resolved=str(real_path))
141
+ raise RemotePathError(f"Path escapes the allowed directory: {real_path}")
142
+
143
+ try:
144
+ # File I/O must not block the event loop; a FIFO would otherwise
145
+ # hang the whole MCP server.
146
+ data, size = await asyncio.to_thread(_read_regular_file, real_path, limit)
147
+ except OSError as e:
148
+ raise RemoteFileError(f"Cannot read {real_path}: {e}") from e
149
+
150
+ text = data.decode("utf-8", errors="replace")
151
+ sanitized = sanitize_output(text, limit, label="file contents")
152
+ self.audit.log(
153
+ "file_read",
154
+ path=str(real_path),
155
+ size=size,
156
+ executor="local",
157
+ truncated=len(data) > limit or sanitized != text,
158
+ )
159
+ return sanitized
160
+
161
+ async def list_remote_dir(
162
+ self,
163
+ path: str,
164
+ *,
165
+ allowed_base: str | None = None,
166
+ ) -> list[RemoteDirEntry]:
167
+ """List a local directory with the same confinement rules."""
168
+ real_path = Path(path).resolve()
169
+ real_base = Path(allowed_base).resolve() if allowed_base else None
170
+ if real_base is not None and not is_within(str(real_path), str(real_base)):
171
+ self.audit.log("dir_list_blocked", path=path, resolved=str(real_path))
172
+ raise RemotePathError(f"Path escapes the allowed directory: {real_path}")
173
+
174
+ try:
175
+ children = list(real_path.iterdir())
176
+ except OSError as e:
177
+ raise RemoteFileError(f"Cannot list {real_path}: {e}") from e
178
+
179
+ entries = []
180
+ for child in children:
181
+ try:
182
+ info = child.stat()
183
+ except OSError:
184
+ continue
185
+ modified = datetime.fromtimestamp(info.st_mtime).strftime("%b %e %H:%M")
186
+ longname = (
187
+ f"{stat.filemode(info.st_mode)} {info.st_size:>10} "
188
+ f"{modified} {child.name}"
189
+ )
190
+ entries.append(
191
+ RemoteDirEntry(name=child.name, longname=longname, size=info.st_size)
192
+ )
193
+ entries.sort(key=lambda entry: entry.name)
194
+ self.audit.log(
195
+ "dir_list",
196
+ path=str(real_path),
197
+ entries=len(entries),
198
+ executor="local",
199
+ )
200
+ return entries
@@ -0,0 +1,33 @@
1
+ """
2
+ Shell-safe command templates shared by the MCP tool catalogs.
3
+
4
+ Commands are argument vectors, never concatenated shell strings. ``render``
5
+ substitutes values literally and quotes the resulting vector with
6
+ :func:`shlex.join`, so a validated value cannot become shell syntax.
7
+ """
8
+
9
+ import shlex
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Operation:
15
+ """A shell-safe command template.
16
+
17
+ ``argv`` items may contain ``$name`` placeholders. Values are substituted
18
+ literally and the resulting vector is quoted by :func:`shlex.join`.
19
+ """
20
+
21
+ argv: tuple[str, ...]
22
+ read_only: bool = True
23
+ destructive: bool = False
24
+
25
+ def render(self, **params: str | int) -> str:
26
+ """Render the template with ``params`` substituted literally."""
27
+
28
+ def substitute(part: str) -> str:
29
+ for key, value in params.items():
30
+ part = part.replace(f"${key}", str(value))
31
+ return part
32
+
33
+ return shlex.join(substitute(part) for part in self.argv)
@@ -0,0 +1,61 @@
1
+ """
2
+ Output sanitization and size limits.
3
+
4
+ MCP tool output is fed directly to a model. Remote command output is untrusted:
5
+ it can contain terminal escape sequences, control characters, or megabytes of
6
+ data. Everything returned by the SSH layer and the HTTP clients goes through
7
+ :func:`sanitize_output`.
8
+ """
9
+
10
+ import re
11
+
12
+ DEFAULT_MAX_OUTPUT_BYTES = 64_000
13
+
14
+ # CSI/OSC-style terminal escapes (colors, cursor movement, hyperlinks, ...)
15
+ ANSI_ESCAPE_PATTERN = re.compile(
16
+ r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))"
17
+ )
18
+
19
+ # Control characters except tab, newline and carriage return.
20
+ CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
21
+
22
+ # A response that was already truncated at this limit must not be truncated
23
+ # again: composite tools assemble several capped blocks and then apply a final
24
+ # cap, which would otherwise append a second marker.
25
+ TRUNCATION_MARKER_PATTERN = re.compile(r"\n… \[[^\]]+ truncated to (\d+) bytes\]$")
26
+
27
+
28
+ def strip_ansi(text: str) -> str:
29
+ """Remove ANSI terminal escape sequences."""
30
+ return ANSI_ESCAPE_PATTERN.sub("", text)
31
+
32
+
33
+ def sanitize_output(
34
+ text: str,
35
+ max_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
36
+ *,
37
+ label: str = "output",
38
+ ) -> str:
39
+ """Strip escapes/control characters and cap the encoded size.
40
+
41
+ The cap is applied to the UTF-8 encoding so a single response cannot flood
42
+ the model context. A truncation marker is appended when data was dropped.
43
+ """
44
+ if not text:
45
+ return text
46
+
47
+ cleaned = CONTROL_CHAR_PATTERN.sub("", strip_ansi(text))
48
+
49
+ if max_bytes <= 0:
50
+ return cleaned
51
+
52
+ existing = TRUNCATION_MARKER_PATTERN.search(cleaned)
53
+ if existing is not None and int(existing.group(1)) == max_bytes:
54
+ return cleaned
55
+
56
+ encoded = cleaned.encode("utf-8")
57
+ if len(encoded) <= max_bytes:
58
+ return cleaned
59
+
60
+ truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
61
+ return f"{truncated}\n… [{label} truncated to {max_bytes} bytes]"
File without changes
@@ -0,0 +1,118 @@
1
+ """A small in-memory rate limiter for state-changing tool calls.
2
+
3
+ The limiter is scoped to one MCP server process (one client session in
4
+ practice). It is a sliding window over call timestamps: at most ``max_calls``
5
+ calls are allowed in any ``window_seconds`` period. It is deliberately simple
6
+ and local; it is not a substitute for host-level protections.
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ from collections import deque
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class RateLimitConfig:
21
+ """Configuration for the destructive-action rate limiter."""
22
+
23
+ enabled: bool = True
24
+ max_calls: int = 10
25
+ window_seconds: float = 60.0
26
+
27
+ @classmethod
28
+ def from_config(cls, config: dict) -> "RateLimitConfig":
29
+ section = config.get("rate_limit")
30
+ if not isinstance(section, dict):
31
+ return cls()
32
+
33
+ enabled = section.get("enabled", True)
34
+ if not isinstance(enabled, bool):
35
+ logger.warning("Ignoring non-boolean rate_limit.enabled")
36
+ enabled = True
37
+
38
+ max_calls = _positive_int(section.get("max_calls", 10), "rate_limit.max_calls")
39
+ window = _positive_float(
40
+ section.get("window_seconds", 60), "rate_limit.window_seconds"
41
+ )
42
+ return cls(enabled=enabled, max_calls=max_calls, window_seconds=window)
43
+
44
+
45
+ def _positive_int(value: object, name: str) -> int:
46
+ try:
47
+ parsed = int(value) # type: ignore[arg-type]
48
+ except (TypeError, ValueError):
49
+ logger.warning("Ignoring invalid %s: %r", name, value)
50
+ return 10
51
+ if parsed < 1:
52
+ logger.warning("Ignoring non-positive %s: %r", name, value)
53
+ return 10
54
+ return parsed
55
+
56
+
57
+ def _positive_float(value: object, name: str) -> float:
58
+ try:
59
+ parsed = float(value) # type: ignore[arg-type]
60
+ except (TypeError, ValueError):
61
+ logger.warning("Ignoring invalid %s: %r", name, value)
62
+ return 60.0
63
+ if parsed <= 0:
64
+ logger.warning("Ignoring non-positive %s: %r", name, value)
65
+ return 60.0
66
+ return parsed
67
+
68
+
69
+ class RateLimiter:
70
+ """Sliding-window limiter with a monotonic clock (injectable for tests)."""
71
+
72
+ def __init__(
73
+ self,
74
+ max_calls: int,
75
+ window_seconds: float,
76
+ *,
77
+ clock: Callable[[], float] = time.monotonic,
78
+ ):
79
+ if max_calls < 1:
80
+ raise ValueError("max_calls must be at least 1")
81
+ if window_seconds <= 0:
82
+ raise ValueError("window_seconds must be positive")
83
+ self.max_calls = max_calls
84
+ self.window_seconds = float(window_seconds)
85
+ self._clock = clock
86
+ self._calls: deque[float] = deque()
87
+ self._lock = threading.Lock()
88
+
89
+ def _prune(self, now: float) -> None:
90
+ cutoff = now - self.window_seconds
91
+ while self._calls and self._calls[0] <= cutoff:
92
+ self._calls.popleft()
93
+
94
+ def check(self) -> float | None:
95
+ """Record a call; return ``None`` when allowed or the retry delay."""
96
+ with self._lock:
97
+ now = self._clock()
98
+ self._prune(now)
99
+ if len(self._calls) >= self.max_calls:
100
+ return max(0.0, self.window_seconds - (now - self._calls[0]))
101
+ self._calls.append(now)
102
+ return None
103
+
104
+ def reset(self) -> None:
105
+ """Forget recorded calls (used by tests and session teardown)."""
106
+ with self._lock:
107
+ self._calls.clear()
108
+
109
+
110
+ def create_rate_limiter(config: dict) -> RateLimiter | None:
111
+ """Build the destructive-action limiter, or ``None`` when disabled."""
112
+ settings = RateLimitConfig.from_config(config)
113
+ if not settings.enabled:
114
+ return None
115
+ return RateLimiter(
116
+ settings.max_calls,
117
+ settings.window_seconds,
118
+ )
@@ -0,0 +1,191 @@
1
+ """
2
+ Redaction helpers for file contents returned to the model.
3
+
4
+ ``.env`` files are useful for debugging a compose stack, but their values are
5
+ secrets. :func:`redact_env_content` keeps the keys, comments and structure so
6
+ the file stays readable while every assignment value is masked.
7
+ """
8
+
9
+ import re
10
+
11
+ REDACTED_VALUE = "***"
12
+
13
+ # ``KEY=value`` and ``export KEY=value`` with optional whitespace around ``=``.
14
+ _ASSIGNMENT_PATTERN = re.compile(
15
+ r"^(?P<prefix>\s*(?:export\s+)?)"
16
+ r"(?P<key>[A-Za-z_][A-Za-z0-9_.-]*)"
17
+ r"(?P<sep>\s*=\s*)(?P<value>.*)$"
18
+ )
19
+
20
+
21
+ def redact_env_content(text: str) -> str:
22
+ """Mask the values of dotenv assignments, preserving the file structure.
23
+
24
+ Comments and blank lines are kept unchanged. Recognized assignments keep
25
+ their key and separator (``KEY=***``). Any other non-empty line is treated
26
+ as a value or a continuation of one (for example a multi-line quoted
27
+ secret) and is masked entirely, so no value text can leak. A trailing
28
+ newline in the input is preserved.
29
+
30
+ Args:
31
+ text: Raw file contents.
32
+
33
+ Returns:
34
+ The contents with every assignment value replaced by ``***``.
35
+ """
36
+ if not text:
37
+ return text
38
+
39
+ lines: list[str] = []
40
+ for line in text.splitlines():
41
+ stripped = line.lstrip()
42
+ if not stripped or stripped.startswith("#"):
43
+ lines.append(line)
44
+ continue
45
+
46
+ match = _ASSIGNMENT_PATTERN.match(line)
47
+ if match is None:
48
+ lines.append(REDACTED_VALUE)
49
+ continue
50
+
51
+ lines.append(
52
+ f"{match.group('prefix')}{match.group('key')}"
53
+ f"{match.group('sep')}{REDACTED_VALUE}"
54
+ )
55
+
56
+ redacted = "\n".join(lines)
57
+ if text.endswith("\n"):
58
+ redacted += "\n"
59
+ return redacted
60
+
61
+
62
+ # ============================================================================
63
+ # General secret redaction (service config files and host status/log output)
64
+ # ============================================================================
65
+
66
+ # Key names whose values are secrets. Matching is on the key itself, so
67
+ # ``POSTGRES_PASSWORD``, ``GITHUB_TOKEN`` and ``api_key`` are covered without
68
+ # treating unrelated ``*.key`` file names as secrets (that policy lives in
69
+ # ``security.BLOCKED_PATH_*``).
70
+ _SENSITIVE_KEY_WORD = re.compile(
71
+ r"(?:password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|"
72
+ r"authorization|access[_-]?key|client[_-]?secret|credential)",
73
+ re.IGNORECASE,
74
+ )
75
+
76
+ # JSON object member: "key": "value" or "key": value
77
+ _JSON_MEMBER = re.compile(
78
+ r'(?P<prefix>"(?P<key>[^"\n]+)"\s*:\s*)'
79
+ r'(?P<value>"(?:[^"\\]|\\.)*"|[^,}\n]+)'
80
+ )
81
+
82
+ # YAML/INI/CLI assignment at the start of a line, including ``- KEY=value``.
83
+ _LINE_ASSIGNMENT = re.compile(
84
+ r"^(?P<prefix>\s*(?:-\s*)?)"
85
+ r'(?P<key>"?[A-Za-z0-9_.-]+"?)'
86
+ r"(?P<sep>\s*[:=]\s*)"
87
+ r"(?P<value>.*)$"
88
+ )
89
+
90
+ # KEY=value anywhere on the line (environment assignments in command lines).
91
+ _INLINE_ASSIGNMENT = re.compile(
92
+ r"(?P<key>\b[A-Za-z_][A-Za-z0-9_.-]*)(?P<sep>=)"
93
+ r'(?P<value>"[^"]*"|\'[^\']*\'|[^\s,;}]+)'
94
+ )
95
+
96
+ # --token value and --password=value command-line flags.
97
+ _CLI_FLAG = re.compile(
98
+ r"(?P<key>--?[A-Za-z0-9_.-]+)(?P<sep>[=\s]+)"
99
+ r'(?P<value>"[^"]*"|\'[^\']*\'|[^\s,;}]+)'
100
+ )
101
+
102
+ _BEARER_VALUE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
103
+ _JWT_VALUE = re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")
104
+ _URL_PASSWORD = re.compile(
105
+ r"(?P<scheme>\b[a-z][a-z0-9+.-]*://)(?P<user>[^/\s:@]+):"
106
+ r"(?P<password>[^/\s@]+)@",
107
+ re.IGNORECASE,
108
+ )
109
+
110
+
111
+ def _is_sensitive_key(key: str) -> bool:
112
+ """Return True when an assignment key names a credential."""
113
+ cleaned = key.strip().strip("\"'").lstrip("-")
114
+ return bool(cleaned) and _SENSITIVE_KEY_WORD.search(cleaned) is not None
115
+
116
+
117
+ def _redact_line(line: str) -> str:
118
+ if not line:
119
+ return line
120
+
121
+ stripped = line.lstrip()
122
+ if stripped.startswith(("#", ";", "//")):
123
+ return line
124
+
125
+ json_changed = False
126
+
127
+ def json_replacement(match: re.Match[str]) -> str:
128
+ nonlocal json_changed
129
+ if _is_sensitive_key(match.group("key")):
130
+ json_changed = True
131
+ return f'{match.group("prefix")}"***"'
132
+ return match.group(0)
133
+
134
+ line = _JSON_MEMBER.sub(json_replacement, line)
135
+
136
+ if not json_changed:
137
+ line_match = _LINE_ASSIGNMENT.match(line)
138
+ if line_match is not None and _is_sensitive_key(line_match.group("key")):
139
+ line = (
140
+ f"{line_match.group('prefix')}{line_match.group('key')}"
141
+ f"{line_match.group('sep')}{REDACTED_VALUE}"
142
+ )
143
+
144
+ def assignment_replacement(match: re.Match[str]) -> str:
145
+ if _is_sensitive_key(match.group("key")):
146
+ return f"{match.group('key')}{match.group('sep')}{REDACTED_VALUE}"
147
+ return match.group(0)
148
+
149
+ line = _INLINE_ASSIGNMENT.sub(assignment_replacement, line)
150
+
151
+ def flag_replacement(match: re.Match[str]) -> str:
152
+ if _is_sensitive_key(match.group("key")):
153
+ return f"{match.group('key')}{match.group('sep')}{REDACTED_VALUE}"
154
+ return match.group(0)
155
+
156
+ line = _CLI_FLAG.sub(flag_replacement, line)
157
+ line = _BEARER_VALUE.sub(f"Bearer {REDACTED_VALUE}", line)
158
+ line = _JWT_VALUE.sub(REDACTED_VALUE, line)
159
+ line = _URL_PASSWORD.sub(
160
+ lambda match: f"{match.group('scheme')}{match.group('user')}:{REDACTED_VALUE}@",
161
+ line,
162
+ )
163
+ return line
164
+
165
+
166
+ def redact_secrets(text: str) -> str:
167
+ """Mask credentials in service config files and host status/log output.
168
+
169
+ The redactor handles YAML/INI/dotenv assignments, JSON members, command-line
170
+ flags, inline ``KEY=value`` pairs, ``Authorization: Bearer`` values, JWT
171
+ shapes and credentials embedded in URLs. Keys, comments and the surrounding
172
+ structure stay readable; only the value is replaced with ``***``.
173
+
174
+ This is deliberately applied in addition to, not instead of, the sensitive
175
+ file policy. Real key material and credential stores are still refused
176
+ before any content is read.
177
+
178
+ Args:
179
+ text: File contents or command output.
180
+
181
+ Returns:
182
+ The text with recognized secret values masked.
183
+ """
184
+ if not text:
185
+ return text
186
+
187
+ lines = [_redact_line(line) for line in text.splitlines()]
188
+ redacted = "\n".join(lines)
189
+ if text.endswith("\n"):
190
+ redacted += "\n"
191
+ return redacted