@neoline/hostbridge 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +350 -0
- package/bin/hostbridge.js +51 -0
- package/bin/server-control-mcp.js +2 -0
- package/examples/claude_desktop_config.json +8 -0
- package/examples/codex.config.toml +7 -0
- package/examples/hosts.example.json +11 -0
- package/package.json +34 -0
- package/pyproject.toml +89 -0
- package/src/server_control_mcp/__init__.py +54 -0
- package/src/server_control_mcp/__main__.py +16 -0
- package/src/server_control_mcp/cli.py +1178 -0
- package/src/server_control_mcp/hosts.py +243 -0
- package/src/server_control_mcp/manager.py +512 -0
- package/src/server_control_mcp/policy.py +187 -0
- package/src/server_control_mcp/secrets.py +54 -0
- package/src/server_control_mcp/server.py +249 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
import pexpect
|
|
14
|
+
|
|
15
|
+
from .hosts import HostRegistry
|
|
16
|
+
from .policy import AuditSink, PolicyConfig, PolicyDecision, audit_event
|
|
17
|
+
|
|
18
|
+
DEFAULT_CONNECT_TIMEOUT = 30
|
|
19
|
+
DEFAULT_COMMAND_TIMEOUT = 60
|
|
20
|
+
DEFAULT_REAPER_INTERVAL_SECONDS = 60
|
|
21
|
+
MAX_OUTPUT_CHARS = 120_000
|
|
22
|
+
REMOTE_JOB_ROOT = "$HOME/.hostbridge/jobs"
|
|
23
|
+
REMOTE_SHELL = "bash"
|
|
24
|
+
|
|
25
|
+
class PolicyDeniedError(RuntimeError):
|
|
26
|
+
"""Raised when a command is blocked by the configured policy."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ChildProcess(Protocol):
|
|
30
|
+
before: str
|
|
31
|
+
after: object
|
|
32
|
+
|
|
33
|
+
def sendline(self, line: str) -> None: ...
|
|
34
|
+
|
|
35
|
+
def send(self, text: str) -> None: ...
|
|
36
|
+
|
|
37
|
+
def expect(self, patterns: object, timeout: int | float = -1) -> int: ...
|
|
38
|
+
|
|
39
|
+
def isalive(self) -> bool: ...
|
|
40
|
+
|
|
41
|
+
def close(self, force: bool = False) -> None: ...
|
|
42
|
+
|
|
43
|
+
def read_nonblocking(self, size: int = 1, timeout: int | float = -1) -> str: ...
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ChildFactory = Callable[[str, list[str], int | float], ChildProcess]
|
|
47
|
+
TokenFactory = Callable[[], str]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RemoteCommandError(RuntimeError):
|
|
51
|
+
"""Raised when a remote session cannot execute a requested operation."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class RemoteSession:
|
|
56
|
+
id: str
|
|
57
|
+
host_id: str
|
|
58
|
+
label: str
|
|
59
|
+
command: str
|
|
60
|
+
args: list[str]
|
|
61
|
+
child: ChildProcess
|
|
62
|
+
created_at: float = field(default_factory=time.time)
|
|
63
|
+
last_used_at: float = field(default_factory=time.time)
|
|
64
|
+
busy: bool = False
|
|
65
|
+
last_output: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class CommandResult:
|
|
70
|
+
session_id: str
|
|
71
|
+
host_id: str
|
|
72
|
+
command: str
|
|
73
|
+
exit_code: int | None
|
|
74
|
+
output: str
|
|
75
|
+
timed_out: bool = False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class RemoteTask:
|
|
80
|
+
id: str
|
|
81
|
+
session_id: str
|
|
82
|
+
host_id: str
|
|
83
|
+
command: str
|
|
84
|
+
job_dir: str
|
|
85
|
+
remote_pid: int | None
|
|
86
|
+
created_at: float = field(default_factory=time.time)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _new_token() -> str:
|
|
90
|
+
return uuid.uuid4().hex[:12]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _default_child_factory(command: str, args: list[str], timeout: int | float) -> ChildProcess:
|
|
94
|
+
return pexpect.spawn(
|
|
95
|
+
command,
|
|
96
|
+
args,
|
|
97
|
+
encoding="utf-8",
|
|
98
|
+
codec_errors="replace",
|
|
99
|
+
timeout=timeout,
|
|
100
|
+
echo=False,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _tail(value: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
|
105
|
+
if len(value) <= limit:
|
|
106
|
+
return value
|
|
107
|
+
return value[-limit:]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _extract_between_markers(raw: str, start_marker: str) -> str:
|
|
111
|
+
if start_marker in raw:
|
|
112
|
+
raw = raw.split(start_marker, 1)[1]
|
|
113
|
+
lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
114
|
+
cleaned = [line for line in lines if not line.startswith("printf '") and "__SCM_EXIT_" not in line]
|
|
115
|
+
return "\n".join(cleaned).strip("\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_exit_marker(marker: object, token: str) -> int | None:
|
|
119
|
+
text = marker.group(0) if hasattr(marker, "group") else str(marker)
|
|
120
|
+
match = re.search(rf"__SCM_EXIT_{re.escape(token)}:(\d+)", text)
|
|
121
|
+
return int(match.group(1)) if match else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _contains_terminal_control(text: str) -> bool:
|
|
125
|
+
return "\x1b" in text or "\x07" in text or "\\x1b" in text or "\\u001b" in text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _looks_shell_prompt(text: str) -> bool:
|
|
129
|
+
return re.search(r"[#$](?:\\s\*)?$", text.strip()) is not None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _runtime_expect_pattern(pattern: str) -> str:
|
|
133
|
+
if _contains_terminal_control(pattern) and _looks_shell_prompt(pattern):
|
|
134
|
+
return r"(?m)[^\r\n]*[#$]\s*$"
|
|
135
|
+
return pattern
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class SessionManager:
|
|
139
|
+
"""Manages long-lived interactive PTYs and remote background jobs."""
|
|
140
|
+
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
hosts: HostRegistry,
|
|
144
|
+
*,
|
|
145
|
+
child_factory: ChildFactory = _default_child_factory,
|
|
146
|
+
remote_shell: str = REMOTE_SHELL,
|
|
147
|
+
policy: PolicyConfig | None = None,
|
|
148
|
+
audit_sink: AuditSink | None = None,
|
|
149
|
+
):
|
|
150
|
+
self.hosts = hosts
|
|
151
|
+
self.child_factory = child_factory
|
|
152
|
+
self.remote_shell = remote_shell
|
|
153
|
+
self._sessions: dict[str, RemoteSession] = {}
|
|
154
|
+
self._tasks: dict[str, RemoteTask] = {}
|
|
155
|
+
self._policy = policy
|
|
156
|
+
self._audit = audit_sink
|
|
157
|
+
self._reaper_stop = threading.Event()
|
|
158
|
+
self._reaper_thread: threading.Thread | None = None
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def policy(self) -> PolicyConfig | None:
|
|
162
|
+
return self._policy
|
|
163
|
+
|
|
164
|
+
def start_idle_reaper(self, *, interval: int = DEFAULT_REAPER_INTERVAL_SECONDS) -> None:
|
|
165
|
+
"""Spawn a background thread that closes idle sessions past policy timeout."""
|
|
166
|
+
|
|
167
|
+
if self._policy is None or not self._policy.enabled or self._policy.idle_timeout_seconds <= 0:
|
|
168
|
+
return
|
|
169
|
+
if self._reaper_thread is not None and self._reaper_thread.is_alive():
|
|
170
|
+
return
|
|
171
|
+
self._reaper_stop.clear()
|
|
172
|
+
timeout = self._policy.idle_timeout_seconds
|
|
173
|
+
thread = threading.Thread(
|
|
174
|
+
target=self._reap_loop,
|
|
175
|
+
kwargs={"interval": interval, "timeout": timeout},
|
|
176
|
+
daemon=True,
|
|
177
|
+
name="scm-idle-reaper",
|
|
178
|
+
)
|
|
179
|
+
self._reaper_thread = thread
|
|
180
|
+
thread.start()
|
|
181
|
+
|
|
182
|
+
def stop_idle_reaper(self) -> None:
|
|
183
|
+
self._reaper_stop.set()
|
|
184
|
+
thread = self._reaper_thread
|
|
185
|
+
if thread is not None and thread.is_alive():
|
|
186
|
+
thread.join(timeout=2.0)
|
|
187
|
+
self._reaper_thread = None
|
|
188
|
+
|
|
189
|
+
def _reap_loop(self, *, interval: int, timeout: int) -> None:
|
|
190
|
+
while not self._reaper_stop.wait(interval):
|
|
191
|
+
self._reap_idle(timeout)
|
|
192
|
+
|
|
193
|
+
def _reap_idle(self, timeout: int) -> int:
|
|
194
|
+
now = time.time()
|
|
195
|
+
expired = [
|
|
196
|
+
sid for sid, session in list(self._sessions.items())
|
|
197
|
+
if not session.busy and (now - session.last_used_at) > timeout
|
|
198
|
+
]
|
|
199
|
+
for sid in expired:
|
|
200
|
+
self._safe_close(sid)
|
|
201
|
+
self._emit("session_idle_closed", session_id=sid, detail=f"idle > {timeout}s")
|
|
202
|
+
return len(expired)
|
|
203
|
+
|
|
204
|
+
def _safe_close(self, session_id: str) -> None:
|
|
205
|
+
session = self._sessions.pop(session_id, None)
|
|
206
|
+
if session is not None:
|
|
207
|
+
with suppress(Exception):
|
|
208
|
+
session.child.close(force=True)
|
|
209
|
+
|
|
210
|
+
def _emit(self, event: str, **fields: object) -> None:
|
|
211
|
+
if self._audit is None:
|
|
212
|
+
return
|
|
213
|
+
audit_event(self._audit, event=event, **{k: v for k, v in fields.items() if v is not None}) # type: ignore[arg-type]
|
|
214
|
+
|
|
215
|
+
def _check_policy(self, command: str) -> PolicyDecision | None:
|
|
216
|
+
if self._policy is None:
|
|
217
|
+
return None
|
|
218
|
+
return self._policy.evaluate(command)
|
|
219
|
+
|
|
220
|
+
def open_session(
|
|
221
|
+
self,
|
|
222
|
+
host_id: str,
|
|
223
|
+
*,
|
|
224
|
+
connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
|
|
225
|
+
token_factory: TokenFactory = _new_token,
|
|
226
|
+
) -> RemoteSession:
|
|
227
|
+
host = self.hosts.get(host_id)
|
|
228
|
+
child = self.child_factory(host.command, list(host.args), connect_timeout)
|
|
229
|
+
token = token_factory()
|
|
230
|
+
marker = f"__SCM_READY_{token}__"
|
|
231
|
+
try:
|
|
232
|
+
self._run_login_steps(child, host, connect_timeout)
|
|
233
|
+
if getattr(host, "login_steps", []):
|
|
234
|
+
self._wait_for_ready_prompt(child, host, connect_timeout)
|
|
235
|
+
child.send(f"printf '\\n{marker}\\n'\r")
|
|
236
|
+
child.expect(re.escape(marker), timeout=connect_timeout)
|
|
237
|
+
except Exception as exc: # pexpect.TIMEOUT/EOF and fake child errors are surfaced uniformly.
|
|
238
|
+
try:
|
|
239
|
+
child.close(force=True)
|
|
240
|
+
finally:
|
|
241
|
+
raise RemoteCommandError(f"failed to establish ready shell for {host_id}: {exc}") from exc
|
|
242
|
+
|
|
243
|
+
session = RemoteSession(
|
|
244
|
+
id=uuid.uuid4().hex,
|
|
245
|
+
host_id=host.id,
|
|
246
|
+
label=host.label,
|
|
247
|
+
command=host.command,
|
|
248
|
+
args=list(host.args),
|
|
249
|
+
child=child,
|
|
250
|
+
last_output=_tail(str(child.before)),
|
|
251
|
+
)
|
|
252
|
+
self._sessions[session.id] = session
|
|
253
|
+
self._emit("session_opened", session_id=session.id, host_id=host.id, outcome="ok")
|
|
254
|
+
return session
|
|
255
|
+
|
|
256
|
+
def _run_login_steps(self, child: ChildProcess, host: object, timeout: int) -> None:
|
|
257
|
+
for step in getattr(host, "login_steps", []):
|
|
258
|
+
child.expect(_runtime_expect_pattern(step.expect), timeout=timeout)
|
|
259
|
+
if step.send_secret is not None:
|
|
260
|
+
secrets = getattr(host, "secrets", {})
|
|
261
|
+
if step.send_secret not in secrets:
|
|
262
|
+
raise RemoteCommandError(f"login step references missing secret '{step.send_secret}'")
|
|
263
|
+
self._send_login_text(child, secrets[step.send_secret])
|
|
264
|
+
else:
|
|
265
|
+
self._send_login_text(child, step.send or "")
|
|
266
|
+
|
|
267
|
+
def _send_login_text(self, child: ChildProcess, text: str) -> None:
|
|
268
|
+
# JumpServer-style menus often require carriage return rather than LF.
|
|
269
|
+
child.send(f"{text}\r")
|
|
270
|
+
|
|
271
|
+
def _wait_for_ready_prompt(self, child: ChildProcess, host: object, timeout: int) -> None:
|
|
272
|
+
ready_expect = getattr(host, "ready_expect", None)
|
|
273
|
+
pattern = ready_expect if isinstance(ready_expect, str) and ready_expect.strip() else r"(?m)[^\r\n]*[#$]\s*$"
|
|
274
|
+
child.expect(_runtime_expect_pattern(pattern), timeout=timeout)
|
|
275
|
+
|
|
276
|
+
def list_sessions(self) -> list[dict[str, object]]:
|
|
277
|
+
return [self._describe_session(session) for session in self._sessions.values()]
|
|
278
|
+
|
|
279
|
+
def close_all_sessions(self) -> dict[str, object]:
|
|
280
|
+
"""Close every live session. Used for graceful shutdown."""
|
|
281
|
+
|
|
282
|
+
closed: list[str] = []
|
|
283
|
+
for session_id in list(self._sessions.keys()):
|
|
284
|
+
self._safe_close(session_id)
|
|
285
|
+
self._emit("session_closed", session_id=session_id, outcome="shutdown")
|
|
286
|
+
closed.append(session_id)
|
|
287
|
+
return {"closed": closed, "count": len(closed)}
|
|
288
|
+
|
|
289
|
+
def close_session(self, session_id: str) -> dict[str, object]:
|
|
290
|
+
session = self._get_session(session_id)
|
|
291
|
+
session.child.close(force=True)
|
|
292
|
+
self._sessions.pop(session_id, None)
|
|
293
|
+
self._emit("session_closed", session_id=session_id, host_id=session.host_id, outcome="user")
|
|
294
|
+
return {"session_id": session_id, "closed": True}
|
|
295
|
+
|
|
296
|
+
def stop_task(self, session_id: str, task_id: str) -> dict[str, object]:
|
|
297
|
+
"""Best-effort terminate a background task by killing its remote PID."""
|
|
298
|
+
|
|
299
|
+
task = self._tasks.get(task_id)
|
|
300
|
+
if task is None:
|
|
301
|
+
raise RemoteCommandError(f"unknown task {task_id}")
|
|
302
|
+
if task.session_id != session_id:
|
|
303
|
+
raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
|
|
304
|
+
if task.remote_pid is None:
|
|
305
|
+
raise RemoteCommandError(f"task {task_id} has no recorded remote pid")
|
|
306
|
+
kill_script = f'if [ -f "{task.job_dir}/exit_code" ]; then echo already; else kill -TERM {task.remote_pid} 2>/dev/null && echo killed || echo noop; fi'
|
|
307
|
+
result = self.run_command(session_id, kill_script, timeout=10)
|
|
308
|
+
outcome = result.output.strip().splitlines()[-1] if result.output.strip() else "unknown"
|
|
309
|
+
self._emit(
|
|
310
|
+
"task_stopped",
|
|
311
|
+
session_id=session_id,
|
|
312
|
+
task_id=task_id,
|
|
313
|
+
host_id=task.host_id,
|
|
314
|
+
outcome=outcome,
|
|
315
|
+
)
|
|
316
|
+
return {"task_id": task_id, "stopped": outcome in ("killed", "already"), "outcome": outcome}
|
|
317
|
+
|
|
318
|
+
def run_command(
|
|
319
|
+
self,
|
|
320
|
+
session_id: str,
|
|
321
|
+
command: str,
|
|
322
|
+
*,
|
|
323
|
+
timeout: int = DEFAULT_COMMAND_TIMEOUT,
|
|
324
|
+
token_factory: TokenFactory = _new_token,
|
|
325
|
+
) -> CommandResult:
|
|
326
|
+
if not command.strip():
|
|
327
|
+
raise RemoteCommandError("command must not be empty")
|
|
328
|
+
decision = self._check_policy(command)
|
|
329
|
+
if decision is not None and not decision.allowed:
|
|
330
|
+
self._emit(
|
|
331
|
+
"command_denied",
|
|
332
|
+
session_id=session_id,
|
|
333
|
+
command=command,
|
|
334
|
+
outcome=decision.reason,
|
|
335
|
+
detail=decision.matched_pattern,
|
|
336
|
+
)
|
|
337
|
+
raise PolicyDeniedError(decision.reason)
|
|
338
|
+
session = self._get_session(session_id)
|
|
339
|
+
if session.busy:
|
|
340
|
+
raise RemoteCommandError(f"session {session_id} is busy")
|
|
341
|
+
if not session.child.isalive():
|
|
342
|
+
raise RemoteCommandError(f"session {session_id} is not alive")
|
|
343
|
+
|
|
344
|
+
token = token_factory()
|
|
345
|
+
start_marker = f"__SCM_START_{token}__"
|
|
346
|
+
exit_pattern = re.compile(rf"__SCM_EXIT_{re.escape(token)}:(\d+)")
|
|
347
|
+
remote_line = self._build_remote_line(command, token, start_marker)
|
|
348
|
+
session.busy = True
|
|
349
|
+
try:
|
|
350
|
+
session.child.sendline(remote_line)
|
|
351
|
+
session.child.expect(exit_pattern, timeout=timeout)
|
|
352
|
+
output = _extract_between_markers(str(session.child.before), start_marker)
|
|
353
|
+
exit_code = _parse_exit_marker(session.child.after, token)
|
|
354
|
+
result = CommandResult(session.id, session.host_id, command, exit_code, _tail(output))
|
|
355
|
+
self._emit(
|
|
356
|
+
"command_run",
|
|
357
|
+
session_id=session.id,
|
|
358
|
+
host_id=session.host_id,
|
|
359
|
+
command=command,
|
|
360
|
+
outcome=f"exit={exit_code}",
|
|
361
|
+
)
|
|
362
|
+
except pexpect.TIMEOUT:
|
|
363
|
+
output = _tail(str(session.child.before))
|
|
364
|
+
result = CommandResult(session.id, session.host_id, command, None, output, timed_out=True)
|
|
365
|
+
self._emit(
|
|
366
|
+
"command_run",
|
|
367
|
+
session_id=session.id,
|
|
368
|
+
host_id=session.host_id,
|
|
369
|
+
command=command,
|
|
370
|
+
outcome="timeout",
|
|
371
|
+
)
|
|
372
|
+
finally:
|
|
373
|
+
session.busy = False
|
|
374
|
+
session.last_used_at = time.time()
|
|
375
|
+
session.last_output = _tail(str(session.child.before))
|
|
376
|
+
return result
|
|
377
|
+
|
|
378
|
+
def start_task(
|
|
379
|
+
self,
|
|
380
|
+
session_id: str,
|
|
381
|
+
command: str,
|
|
382
|
+
*,
|
|
383
|
+
token_factory: TokenFactory = _new_token,
|
|
384
|
+
) -> RemoteTask:
|
|
385
|
+
if not command.strip():
|
|
386
|
+
raise RemoteCommandError("command must not be empty")
|
|
387
|
+
decision = self._check_policy(command)
|
|
388
|
+
if decision is not None and not decision.allowed:
|
|
389
|
+
self._emit(
|
|
390
|
+
"task_denied",
|
|
391
|
+
session_id=session_id,
|
|
392
|
+
command=command,
|
|
393
|
+
outcome=decision.reason,
|
|
394
|
+
detail=decision.matched_pattern,
|
|
395
|
+
)
|
|
396
|
+
raise PolicyDeniedError(decision.reason)
|
|
397
|
+
task_id = token_factory()
|
|
398
|
+
job_dir = f"{REMOTE_JOB_ROOT}/{task_id}"
|
|
399
|
+
quoted_command = shlex.quote(command)
|
|
400
|
+
script = (
|
|
401
|
+
f'job_dir="{job_dir}"; '
|
|
402
|
+
'mkdir -p "$job_dir"; '
|
|
403
|
+
'date -u +%Y-%m-%dT%H:%M:%SZ > "$job_dir/started_at"; '
|
|
404
|
+
f'({self.remote_shell} -lc {quoted_command} > "$job_dir/stdout.log" 2>&1; '
|
|
405
|
+
'status=$?; printf "%s\\n" "$status" > "$job_dir/exit_code"; '
|
|
406
|
+
'date -u +%Y-%m-%dT%H:%M:%SZ > "$job_dir/finished_at") & '
|
|
407
|
+
'pid=$!; printf "%s\\n" "$pid" > "$job_dir/pid"; printf "%s" "$pid"'
|
|
408
|
+
)
|
|
409
|
+
result = self.run_command(session_id, script, timeout=10, token_factory=lambda: f"task_{task_id}")
|
|
410
|
+
pid_match = re.search(r"(\d+)", result.output)
|
|
411
|
+
task = RemoteTask(
|
|
412
|
+
id=task_id,
|
|
413
|
+
session_id=session_id,
|
|
414
|
+
host_id=self._get_session(session_id).host_id,
|
|
415
|
+
command=command,
|
|
416
|
+
job_dir=job_dir,
|
|
417
|
+
remote_pid=int(pid_match.group(1)) if pid_match else None,
|
|
418
|
+
)
|
|
419
|
+
self._tasks[task.id] = task
|
|
420
|
+
self._emit("task_started", session_id=session_id, task_id=task.id, host_id=task.host_id, command=command, outcome="ok")
|
|
421
|
+
return task
|
|
422
|
+
|
|
423
|
+
def task_status(self, session_id: str, task_id: str, *, tail_lines: int = 80) -> dict[str, object]:
|
|
424
|
+
task = self._tasks.get(task_id)
|
|
425
|
+
if task is None:
|
|
426
|
+
raise RemoteCommandError(f"unknown task {task_id}")
|
|
427
|
+
if task.session_id != session_id:
|
|
428
|
+
raise RemoteCommandError(f"task {task_id} does not belong to session {session_id}")
|
|
429
|
+
safe_tail_lines = max(1, min(int(tail_lines), 500))
|
|
430
|
+
script = (
|
|
431
|
+
f'job_dir="{task.job_dir}"; '
|
|
432
|
+
'pid=$(cat "$job_dir/pid" 2>/dev/null || true); '
|
|
433
|
+
'if [ -f "$job_dir/exit_code" ]; then state=finished; exit_code=$(cat "$job_dir/exit_code"); '
|
|
434
|
+
'elif [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then state=running; exit_code=""; '
|
|
435
|
+
'else state=unknown; exit_code=""; fi; '
|
|
436
|
+
'printf "STATE=%s\\nPID=%s\\nEXIT=%s\\n---LOG---\\n" "$state" "$pid" "$exit_code"; '
|
|
437
|
+
f'tail -n {safe_tail_lines} "$job_dir/stdout.log" 2>/dev/null || true'
|
|
438
|
+
)
|
|
439
|
+
result = self.run_command(session_id, script, timeout=15)
|
|
440
|
+
metadata, _, log = result.output.partition("---LOG---\n")
|
|
441
|
+
fields: dict[str, str] = {}
|
|
442
|
+
for line in metadata.splitlines():
|
|
443
|
+
key, sep, value = line.partition("=")
|
|
444
|
+
if sep:
|
|
445
|
+
fields[key] = value
|
|
446
|
+
exit_text = fields.get("EXIT") or ""
|
|
447
|
+
return {
|
|
448
|
+
"task_id": task.id,
|
|
449
|
+
"session_id": session_id,
|
|
450
|
+
"host_id": task.host_id,
|
|
451
|
+
"command": task.command,
|
|
452
|
+
"job_dir": task.job_dir,
|
|
453
|
+
"state": fields.get("STATE", "unknown"),
|
|
454
|
+
"pid": fields.get("PID") or task.remote_pid,
|
|
455
|
+
"exit_code": int(exit_text) if exit_text.isdigit() else None,
|
|
456
|
+
"log_tail": log,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
def write(self, session_id: str, text: str) -> dict[str, object]:
|
|
460
|
+
session = self._get_session(session_id)
|
|
461
|
+
session.child.sendline(text)
|
|
462
|
+
session.last_used_at = time.time()
|
|
463
|
+
return {"session_id": session_id, "written": True}
|
|
464
|
+
|
|
465
|
+
def read(self, session_id: str, *, timeout: float = 1.0, max_chars: int = 20_000) -> dict[str, object]:
|
|
466
|
+
session = self._get_session(session_id)
|
|
467
|
+
chunks: list[str] = []
|
|
468
|
+
deadline = time.time() + max(0.0, timeout)
|
|
469
|
+
while len("".join(chunks)) < max_chars:
|
|
470
|
+
remaining = max(0.0, deadline - time.time())
|
|
471
|
+
if remaining <= 0 and chunks:
|
|
472
|
+
break
|
|
473
|
+
try:
|
|
474
|
+
chunk = session.child.read_nonblocking(size=4096, timeout=remaining)
|
|
475
|
+
except (pexpect.TIMEOUT, pexpect.EOF):
|
|
476
|
+
break
|
|
477
|
+
if not chunk:
|
|
478
|
+
break
|
|
479
|
+
chunks.append(str(chunk))
|
|
480
|
+
output = _tail("".join(chunks), max_chars)
|
|
481
|
+
session.last_output = output or session.last_output
|
|
482
|
+
session.last_used_at = time.time()
|
|
483
|
+
return {"session_id": session_id, "output": output, "alive": session.child.isalive()}
|
|
484
|
+
|
|
485
|
+
def _get_session(self, session_id: str) -> RemoteSession:
|
|
486
|
+
try:
|
|
487
|
+
return self._sessions[session_id]
|
|
488
|
+
except KeyError as exc:
|
|
489
|
+
raise RemoteCommandError(f"unknown session {session_id}") from exc
|
|
490
|
+
|
|
491
|
+
def _build_remote_line(self, command: str, token: str, start_marker: str) -> str:
|
|
492
|
+
quoted_command = shlex.quote(command)
|
|
493
|
+
return (
|
|
494
|
+
f"printf '\\n{start_marker}\\n'; "
|
|
495
|
+
f"{self.remote_shell} -lc {quoted_command}; "
|
|
496
|
+
"__scm_status=$?; "
|
|
497
|
+
f"printf '\\n__SCM_EXIT_{token}:%s\\n' \"$__scm_status\""
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _describe_session(session: RemoteSession) -> dict[str, object]:
|
|
502
|
+
return {
|
|
503
|
+
"session_id": session.id,
|
|
504
|
+
"host_id": session.host_id,
|
|
505
|
+
"label": session.label,
|
|
506
|
+
"command": session.command,
|
|
507
|
+
"args": session.args,
|
|
508
|
+
"alive": session.child.isalive(),
|
|
509
|
+
"busy": session.busy,
|
|
510
|
+
"created_at": session.created_at,
|
|
511
|
+
"last_used_at": session.last_used_at,
|
|
512
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Lightweight policy layer for hostbridge.
|
|
2
|
+
|
|
3
|
+
This module implements an optional, defense-in-depth policy that augments the
|
|
4
|
+
existing allowlist model. It is not an authentication or authorization layer:
|
|
5
|
+
any local process that can invoke the MCP server already runs as the same user
|
|
6
|
+
and can bypass it (e.g. by setting ``HOSTBRIDGE_DISABLE_POLICY=1``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Protocol
|
|
20
|
+
|
|
21
|
+
DEFAULT_POLICY_FILE = Path("~/.hostbridge/policy.json").expanduser()
|
|
22
|
+
LEGACY_POLICY_FILE = Path("~/.server_control_mcp/policy.json").expanduser()
|
|
23
|
+
DEFAULT_AUDIT_LOG = Path("~/.hostbridge/audit.jsonl").expanduser()
|
|
24
|
+
LEGACY_AUDIT_LOG = Path("~/.server_control_mcp/audit.jsonl").expanduser()
|
|
25
|
+
DEFAULT_IDLE_TIMEOUT_SECONDS = 1800
|
|
26
|
+
DEFAULT_REAPER_INTERVAL_SECONDS = 60
|
|
27
|
+
|
|
28
|
+
DEFAULT_DENYLIST_PATTERNS = (
|
|
29
|
+
r"\brm\s+-rf\s+/(?:\s|$)",
|
|
30
|
+
r"\bmkfs\.\w+\b",
|
|
31
|
+
r"\bdd\s+.*\bof=/dev/(?:sd|nvme|hd)",
|
|
32
|
+
r":\(\)\s*\{\s*:\|:\s*&\s*\};",
|
|
33
|
+
r"\bshutdown\b",
|
|
34
|
+
r"\breboot\b",
|
|
35
|
+
r">\s*/dev/sda",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuditSink(Protocol):
|
|
40
|
+
def write(self, event: dict[str, Any]) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _NullAudit:
|
|
44
|
+
def write(self, event: dict[str, Any]) -> None: # noqa: ARG002
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _JsonlAudit:
|
|
49
|
+
def __init__(self, path: Path) -> None:
|
|
50
|
+
self._path = path
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
def write(self, event: dict[str, Any]) -> None:
|
|
54
|
+
line = json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n"
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
with self._path.open("a", encoding="utf-8") as handle:
|
|
58
|
+
handle.write(line)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class PolicyDecision:
|
|
63
|
+
allowed: bool
|
|
64
|
+
reason: str = ""
|
|
65
|
+
matched_pattern: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class PolicyConfig:
|
|
70
|
+
idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
|
|
71
|
+
command_denylist: tuple[re.Pattern[str], ...] = field(default_factory=tuple)
|
|
72
|
+
audit_log_path: Path | None = None
|
|
73
|
+
enabled: bool = True
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def disabled(cls) -> PolicyConfig:
|
|
77
|
+
return cls(enabled=False, audit_log_path=None)
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_dict(cls, raw: dict[str, Any], *, env: dict[str, str] | None = None) -> PolicyConfig:
|
|
81
|
+
env = env if env is not None else dict(os.environ)
|
|
82
|
+
if (env.get("HOSTBRIDGE_DISABLE_POLICY", "") or env.get("SERVER_CONTROL_MCP_DISABLE_POLICY", "")).lower() in (
|
|
83
|
+
"1",
|
|
84
|
+
"true",
|
|
85
|
+
"yes",
|
|
86
|
+
):
|
|
87
|
+
return cls.disabled()
|
|
88
|
+
|
|
89
|
+
raw = raw or {}
|
|
90
|
+
idle_timeout = int(
|
|
91
|
+
env.get(
|
|
92
|
+
"HOSTBRIDGE_IDLE_TIMEOUT",
|
|
93
|
+
env.get("SERVER_CONTROL_MCP_IDLE_TIMEOUT", raw.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS)),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
idle_timeout = max(0, idle_timeout)
|
|
97
|
+
|
|
98
|
+
patterns: list[str] = []
|
|
99
|
+
if raw.get("command_denylist"):
|
|
100
|
+
patterns = [str(p) for p in raw["command_denylist"]]
|
|
101
|
+
elif raw.get("use_default_denylist", True):
|
|
102
|
+
patterns = list(DEFAULT_DENYLIST_PATTERNS)
|
|
103
|
+
env_extra = env.get("HOSTBRIDGE_DENYLIST") or env.get("SERVER_CONTROL_MCP_DENYLIST")
|
|
104
|
+
if env_extra:
|
|
105
|
+
patterns.extend(p for p in env_extra.split(os.pathsep) if p)
|
|
106
|
+
|
|
107
|
+
default_audit = DEFAULT_AUDIT_LOG if DEFAULT_AUDIT_LOG.exists() or not LEGACY_AUDIT_LOG.exists() else LEGACY_AUDIT_LOG
|
|
108
|
+
audit_path_str = env.get(
|
|
109
|
+
"HOSTBRIDGE_AUDIT_LOG",
|
|
110
|
+
env.get("SERVER_CONTROL_MCP_AUDIT_LOG", str(raw.get("audit_log_path") or default_audit)),
|
|
111
|
+
)
|
|
112
|
+
audit_path = Path(audit_path_str).expanduser() if audit_path_str else None
|
|
113
|
+
|
|
114
|
+
return cls(
|
|
115
|
+
idle_timeout_seconds=idle_timeout,
|
|
116
|
+
command_denylist=tuple(re.compile(pattern) for pattern in patterns),
|
|
117
|
+
audit_log_path=audit_path,
|
|
118
|
+
enabled=True,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def evaluate(self, command: str) -> PolicyDecision:
|
|
122
|
+
if not self.enabled:
|
|
123
|
+
return PolicyDecision(allowed=True, reason="policy disabled")
|
|
124
|
+
if not command.strip():
|
|
125
|
+
return PolicyDecision(allowed=False, reason="empty command")
|
|
126
|
+
for pattern in self.command_denylist:
|
|
127
|
+
if pattern.search(command):
|
|
128
|
+
return PolicyDecision(False, "command matched denylist", pattern.pattern)
|
|
129
|
+
return PolicyDecision(True)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_policy(config_path: Path | str | None = None) -> PolicyConfig:
|
|
133
|
+
if (os.environ.get("HOSTBRIDGE_DISABLE_POLICY", "") or os.environ.get("SERVER_CONTROL_MCP_DISABLE_POLICY", "")).lower() in (
|
|
134
|
+
"1",
|
|
135
|
+
"true",
|
|
136
|
+
"yes",
|
|
137
|
+
):
|
|
138
|
+
return PolicyConfig.disabled()
|
|
139
|
+
|
|
140
|
+
path = Path(
|
|
141
|
+
config_path
|
|
142
|
+
or os.environ.get("HOSTBRIDGE_POLICY_FILE")
|
|
143
|
+
or os.environ.get("SERVER_CONTROL_MCP_POLICY_FILE")
|
|
144
|
+
or (DEFAULT_POLICY_FILE if DEFAULT_POLICY_FILE.exists() or not LEGACY_POLICY_FILE.exists() else LEGACY_POLICY_FILE)
|
|
145
|
+
).expanduser()
|
|
146
|
+
|
|
147
|
+
raw: dict[str, Any] = {}
|
|
148
|
+
if path.exists():
|
|
149
|
+
try:
|
|
150
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
151
|
+
if isinstance(data, dict):
|
|
152
|
+
raw = data
|
|
153
|
+
except (OSError, json.JSONDecodeError):
|
|
154
|
+
raw = {}
|
|
155
|
+
return PolicyConfig.from_dict(raw)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_audit_sink(policy: PolicyConfig) -> AuditSink:
|
|
159
|
+
if not policy.enabled or policy.audit_log_path is None:
|
|
160
|
+
return _NullAudit()
|
|
161
|
+
return _JsonlAudit(policy.audit_log_path)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def audit_event(
|
|
165
|
+
sink: AuditSink,
|
|
166
|
+
*,
|
|
167
|
+
event: str,
|
|
168
|
+
session_id: str | None = None,
|
|
169
|
+
host_id: str | None = None,
|
|
170
|
+
command: str | None = None,
|
|
171
|
+
task_id: str | None = None,
|
|
172
|
+
outcome: str | None = None,
|
|
173
|
+
detail: str | None = None,
|
|
174
|
+
clock: Callable[[], float] = time.time,
|
|
175
|
+
) -> None:
|
|
176
|
+
payload: dict[str, Any] = {"ts": clock(), "event": event}
|
|
177
|
+
for key, value in {
|
|
178
|
+
"session_id": session_id,
|
|
179
|
+
"host_id": host_id,
|
|
180
|
+
"command": command,
|
|
181
|
+
"task_id": task_id,
|
|
182
|
+
"outcome": outcome,
|
|
183
|
+
"detail": detail,
|
|
184
|
+
}.items():
|
|
185
|
+
if value is not None:
|
|
186
|
+
payload[key] = value
|
|
187
|
+
sink.write(payload)
|