@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.
@@ -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)