@neoline/hostbridge 2.0.3 → 2.0.5

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 (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -0,0 +1,283 @@
1
+ """Bounded stream state shared by multiplexed RPC clients and servers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Callable
7
+ from contextlib import suppress
8
+ from typing import Protocol
9
+
10
+ from .mux_connection import ByteCreditWindow, StreamLifecycle, StreamState
11
+ from .mux_protocol import SequenceTracker
12
+
13
+
14
+ class MuxStreamOwner(Protocol):
15
+ async def _stream_accept(self, stream: MuxRpcStream) -> None: ...
16
+
17
+ async def _stream_send_data(self, stream: MuxRpcStream, data: bytes) -> None: ...
18
+
19
+ async def _stream_send_eof(self, stream: MuxRpcStream) -> None: ...
20
+
21
+ async def _stream_release_credit(self, stream: MuxRpcStream, amount: int) -> None: ...
22
+
23
+ async def _stream_reset(self, stream: MuxRpcStream, message: str) -> None: ...
24
+
25
+
26
+ class AsyncByteCreditWindow:
27
+ def __init__(self, credit: int, *, on_stall: Callable[[], None] | None = None) -> None:
28
+ if not isinstance(credit, int) or isinstance(credit, bool) or credit <= 0:
29
+ raise ValueError("credit must be a positive integer")
30
+ self._available = credit
31
+ self._maximum = credit
32
+ self._on_stall = on_stall
33
+ self._stall_count = 0
34
+ self._closed_error: BaseException | None = None
35
+ self._condition = asyncio.Condition()
36
+
37
+ @property
38
+ def available(self) -> int:
39
+ return self._available
40
+
41
+ @property
42
+ def maximum(self) -> int:
43
+ return self._maximum
44
+
45
+ @property
46
+ def stall_count(self) -> int:
47
+ return self._stall_count
48
+
49
+ async def acquire(self, amount: int) -> None:
50
+ _positive(amount, "amount")
51
+ if amount > self._maximum:
52
+ raise ValueError("amount exceeds maximum credit")
53
+ async with self._condition:
54
+ if self._closed_error is None and self._available < amount:
55
+ self._stall_count += 1
56
+ if self._on_stall is not None:
57
+ with suppress(Exception):
58
+ self._on_stall()
59
+ await self._condition.wait_for(lambda: self._closed_error is not None or self._available >= amount)
60
+ if self._closed_error is not None:
61
+ raise self._closed_error
62
+ self._available -= amount
63
+
64
+ async def grant(self, amount: int) -> None:
65
+ _positive(amount, "amount")
66
+ async with self._condition:
67
+ if self._closed_error is not None:
68
+ return
69
+ if self._available + amount > self._maximum:
70
+ raise ValueError("credit grant exceeds negotiated maximum")
71
+ self._available += amount
72
+ self._condition.notify_all()
73
+
74
+ async def close(self, error: BaseException) -> None:
75
+ async with self._condition:
76
+ if self._closed_error is None:
77
+ self._closed_error = error
78
+ self._condition.notify_all()
79
+
80
+
81
+ class _ReceiveBuffer:
82
+ def __init__(self) -> None:
83
+ self._buffer = bytearray()
84
+ self._eof = False
85
+ self._error: BaseException | None = None
86
+ self._condition = asyncio.Condition()
87
+
88
+ @property
89
+ def buffered_bytes(self) -> int:
90
+ return len(self._buffer)
91
+
92
+ async def feed_data(self, data: bytes) -> None:
93
+ async with self._condition:
94
+ if self._eof or self._error is not None:
95
+ return
96
+ self._buffer.extend(data)
97
+ self._condition.notify_all()
98
+
99
+ async def feed_eof(self) -> None:
100
+ async with self._condition:
101
+ self._eof = True
102
+ self._condition.notify_all()
103
+
104
+ async def fail(self, error: BaseException) -> None:
105
+ async with self._condition:
106
+ self._buffer.clear()
107
+ self._error = error
108
+ self._condition.notify_all()
109
+
110
+ async def read(self, max_bytes: int) -> bytes | None:
111
+ _positive(max_bytes, "max_bytes")
112
+ async with self._condition:
113
+ await self._condition.wait_for(lambda: bool(self._buffer) or self._eof or self._error is not None)
114
+ if self._error is not None:
115
+ raise self._error
116
+ if self._buffer:
117
+ size = min(max_bytes, len(self._buffer))
118
+ data = bytes(self._buffer[:size])
119
+ del self._buffer[:size]
120
+ return data
121
+ return None
122
+
123
+
124
+ class MuxRpcStream:
125
+ def __init__(
126
+ self,
127
+ owner: MuxStreamOwner,
128
+ stream_id: int,
129
+ sequences: SequenceTracker,
130
+ *,
131
+ send_credit: int,
132
+ receive_credit: int,
133
+ open_initiator: bool,
134
+ result_expected: bool = True,
135
+ accepted: bool = True,
136
+ on_send_stall: Callable[[], None] | None = None,
137
+ ) -> None:
138
+ self._owner = owner
139
+ self.stream_id = stream_id
140
+ self._sequences = sequences
141
+ self._send_window = AsyncByteCreditWindow(send_credit, on_stall=on_send_stall)
142
+ self._receive_window = ByteCreditWindow(
143
+ initial_credit=receive_credit,
144
+ maximum_credit=receive_credit,
145
+ )
146
+ self._receive_buffer = _ReceiveBuffer()
147
+ self._result: asyncio.Future[dict[str, object]] | None = (
148
+ asyncio.get_running_loop().create_future() if result_expected else None
149
+ )
150
+ self._lifecycle = StreamLifecycle(open_initiator=open_initiator)
151
+ if accepted:
152
+ self._lifecycle.mark_open()
153
+
154
+ @property
155
+ def buffered_bytes(self) -> int:
156
+ return self._receive_buffer.buffered_bytes
157
+
158
+ @property
159
+ def closed(self) -> bool:
160
+ return self._lifecycle.terminal
161
+
162
+ @property
163
+ def state(self) -> StreamState:
164
+ return self._lifecycle.state
165
+
166
+ @property
167
+ def local_eof(self) -> bool:
168
+ return self._lifecycle.local_eof
169
+
170
+ @property
171
+ def accepted(self) -> bool:
172
+ return self._lifecycle.opened
173
+
174
+ @property
175
+ def remote_eof(self) -> bool:
176
+ return self._lifecycle.remote_eof
177
+
178
+ @property
179
+ def close_sent(self) -> bool:
180
+ return self._lifecycle.close_sent
181
+
182
+ @property
183
+ def close_received(self) -> bool:
184
+ return self._lifecycle.close_received
185
+
186
+ @property
187
+ def receive_credit(self) -> int:
188
+ return self._receive_window.available
189
+
190
+ async def accept(self) -> None:
191
+ if self.accepted:
192
+ return
193
+ if self.closed:
194
+ raise RuntimeError("stream is closed")
195
+ self._lifecycle.mark_open()
196
+ await self._owner._stream_accept(self)
197
+
198
+ async def send(self, data: bytes) -> None:
199
+ if not isinstance(data, bytes):
200
+ raise TypeError("stream data must be bytes")
201
+ if not data:
202
+ return
203
+ if self.closed:
204
+ raise RuntimeError("stream is closed")
205
+ if not self.accepted:
206
+ raise RuntimeError("stream is not accepted")
207
+ self._lifecycle.validate_send_data()
208
+ await self._owner._stream_send_data(self, data)
209
+
210
+ async def send_eof(self) -> None:
211
+ if self.closed or self.local_eof:
212
+ return
213
+ if not self.accepted:
214
+ raise RuntimeError("stream is not accepted")
215
+ self._lifecycle.send_eof()
216
+ await self._owner._stream_send_eof(self)
217
+
218
+ async def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
219
+ if not self.accepted:
220
+ raise RuntimeError("stream is not accepted")
221
+ data = await self._receive_buffer.read(max_bytes)
222
+ if data is not None:
223
+ await self._owner._stream_release_credit(self, len(data))
224
+ return data
225
+
226
+ async def finish(self) -> dict[str, object]:
227
+ if self._result is None:
228
+ raise RuntimeError("server-owned streams do not receive a terminal result")
229
+ return await self._result
230
+
231
+ async def reset(self, message: str = "stream reset") -> None:
232
+ if self.closed:
233
+ return
234
+ await self._owner._stream_reset(self, message)
235
+
236
+ async def _accept_data(self, data: bytes) -> None:
237
+ self._lifecycle.validate_receive_data()
238
+ self._receive_window.consume(len(data))
239
+ await self._receive_buffer.feed_data(data)
240
+
241
+ async def _accept_eof(self) -> None:
242
+ self._lifecycle.receive_eof()
243
+ await self._receive_buffer.feed_eof()
244
+
245
+ def _send_close(self) -> None:
246
+ self._lifecycle.send_close()
247
+
248
+ def _receive_close(self) -> None:
249
+ self._lifecycle.receive_close()
250
+
251
+ async def _release_receive_credit(self, amount: int) -> None:
252
+ self._receive_window.grant(amount)
253
+
254
+ async def _grant_send_credit(self, amount: int) -> None:
255
+ await self._send_window.grant(amount)
256
+
257
+ async def _fail(self, error: BaseException) -> None:
258
+ if self.closed:
259
+ return
260
+ self._lifecycle.send_reset()
261
+ self._receive_window.close()
262
+ await self._send_window.close(error)
263
+ await self._receive_buffer.fail(error)
264
+ if self._result is not None and not self._result.done():
265
+ self._result.set_exception(error)
266
+ self._result.exception()
267
+
268
+ async def _finish(self, result: dict[str, object]) -> None:
269
+ if self.closed:
270
+ return
271
+ if self._lifecycle.open_initiator:
272
+ self._lifecycle.receive_close_ack()
273
+ else:
274
+ self._lifecycle.send_close_ack()
275
+ self._receive_window.close()
276
+ await self._send_window.close(RuntimeError("stream is closed"))
277
+ if self._result is not None and not self._result.done():
278
+ self._result.set_result(result)
279
+
280
+
281
+ def _positive(value: int, name: str) -> None:
282
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
283
+ raise ValueError(f"{name} must be a positive integer")
@@ -0,0 +1,224 @@
1
+ """Thread-safe synchronous facade for the asyncio multiplexed RPC client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import threading
7
+ from collections.abc import Coroutine, Mapping
8
+ from concurrent.futures import TimeoutError as FutureTimeoutError
9
+ from pathlib import Path
10
+ from types import TracebackType
11
+ from typing import Any, TypeVar
12
+
13
+ from .mux_rpc import MuxConnectionClosed, MuxRpcClient
14
+ from .mux_stream import MuxRpcStream
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class MuxSyncStream:
20
+ def __init__(self, owner: MuxSyncClient, stream: MuxRpcStream) -> None:
21
+ self._owner = owner
22
+ self._stream = stream
23
+
24
+ @property
25
+ def stream_id(self) -> int:
26
+ return self._stream.stream_id
27
+
28
+ @property
29
+ def closed(self) -> bool:
30
+ return self._stream.closed
31
+
32
+ def send(self, data: bytes, *, timeout: float | None = None) -> None:
33
+ self._owner._submit(self._stream.send(data), timeout=timeout)
34
+
35
+ def send_eof(self, *, timeout: float | None = None) -> None:
36
+ self._owner._submit(self._stream.send_eof(), timeout=timeout)
37
+
38
+ def receive(self, max_bytes: int = 64 * 1024, *, timeout: float | None = None) -> bytes | None:
39
+ return self._owner._submit(self._stream.receive(max_bytes), timeout=timeout)
40
+
41
+ def receive_unbounded(self, max_bytes: int = 64 * 1024) -> bytes | None:
42
+ return self._owner._submit_unbounded(self._stream.receive(max_bytes))
43
+
44
+ def finish(self, *, timeout: float | None = None) -> dict[str, object]:
45
+ return self._owner._submit(self._stream.finish(), timeout=timeout)
46
+
47
+ def reset(self, message: str = "stream reset", *, timeout: float | None = None) -> None:
48
+ self._owner._submit(self._stream.reset(message), timeout=timeout)
49
+
50
+
51
+ class MuxSyncClient:
52
+ def __init__(
53
+ self,
54
+ socket_path: Path | str,
55
+ *,
56
+ default_timeout: float = 30.0,
57
+ max_pending: int = 256,
58
+ ) -> None:
59
+ if default_timeout <= 0:
60
+ raise ValueError("default_timeout must be positive")
61
+ if not isinstance(max_pending, int) or isinstance(max_pending, bool) or max_pending <= 0:
62
+ raise ValueError("max_pending must be a positive integer")
63
+ self.socket_path = Path(socket_path)
64
+ self.default_timeout = float(default_timeout)
65
+ self._max_pending = max_pending
66
+ self._loop = asyncio.new_event_loop()
67
+ self._client: MuxRpcClient | None = None
68
+ self._connect_lock: asyncio.Lock | None = None
69
+ self._state_lock = threading.Lock()
70
+ self._started = threading.Event()
71
+ self._closed = False
72
+ self._thread = threading.Thread(target=self._run_loop, name="hostbridge-mux-client", daemon=True)
73
+ self._thread.start()
74
+ self._started.wait()
75
+
76
+ @property
77
+ def closed(self) -> bool:
78
+ with self._state_lock:
79
+ return self._closed
80
+
81
+ @property
82
+ def worker_thread_alive(self) -> bool:
83
+ return self._thread.is_alive()
84
+
85
+ def request(
86
+ self,
87
+ method: str,
88
+ params: Mapping[str, object],
89
+ *,
90
+ timeout: float | None = None,
91
+ ) -> dict[str, object]:
92
+ return self._submit(self._request(method, params), timeout=timeout)
93
+
94
+ def open_stream(
95
+ self,
96
+ method: str,
97
+ params: Mapping[str, object],
98
+ *,
99
+ timeout: float | None = None,
100
+ receive_window: int | None = None,
101
+ ) -> MuxSyncStream:
102
+ stream = self._submit(
103
+ self._open_stream(method, params, receive_window=receive_window),
104
+ timeout=timeout,
105
+ )
106
+ return MuxSyncStream(self, stream)
107
+
108
+ def close(self) -> None:
109
+ with self._state_lock:
110
+ if self._closed:
111
+ return
112
+ self._closed = True
113
+ if self._thread.is_alive():
114
+ future = asyncio.run_coroutine_threadsafe(self._close_client(), self._loop)
115
+ try:
116
+ future.result(timeout=self.default_timeout)
117
+ finally:
118
+ self._loop.call_soon_threadsafe(self._loop.stop)
119
+ if threading.get_ident() != self._thread.ident:
120
+ self._thread.join(timeout=self.default_timeout)
121
+
122
+ def __enter__(self) -> MuxSyncClient:
123
+ return self
124
+
125
+ def __exit__(
126
+ self,
127
+ exc_type: type[BaseException] | None,
128
+ exc: BaseException | None,
129
+ traceback: TracebackType | None,
130
+ ) -> None:
131
+ self.close()
132
+
133
+ async def _request(self, method: str, params: Mapping[str, object]) -> dict[str, object]:
134
+ client = await self._ensure_client()
135
+ try:
136
+ return await client.request(method, params)
137
+ except MuxConnectionClosed:
138
+ await self._discard_client(client)
139
+ raise
140
+
141
+ async def _open_stream(
142
+ self,
143
+ method: str,
144
+ params: Mapping[str, object],
145
+ *,
146
+ receive_window: int | None,
147
+ ) -> MuxRpcStream:
148
+ client = await self._ensure_client()
149
+ try:
150
+ return await client.open_stream(method, params, receive_window=receive_window)
151
+ except MuxConnectionClosed:
152
+ await self._discard_client(client)
153
+ raise
154
+
155
+ async def _ensure_client(self) -> MuxRpcClient:
156
+ current = self._client
157
+ if current is not None and not current.closed:
158
+ return current
159
+ if self._connect_lock is None:
160
+ self._connect_lock = asyncio.Lock()
161
+ async with self._connect_lock:
162
+ current = self._client
163
+ if current is not None and not current.closed:
164
+ return current
165
+ replacement = await MuxRpcClient.connect_unix(self.socket_path, max_pending=self._max_pending)
166
+ self._client = replacement
167
+ if current is not None:
168
+ await current.close()
169
+ return replacement
170
+
171
+ async def _discard_client(self, client: MuxRpcClient) -> None:
172
+ if self._connect_lock is None:
173
+ self._connect_lock = asyncio.Lock()
174
+ async with self._connect_lock:
175
+ if self._client is client:
176
+ self._client = None
177
+ await client.close()
178
+
179
+ async def _close_client(self) -> None:
180
+ if self._client is not None:
181
+ await self._client.close()
182
+ self._client = None
183
+
184
+ def _submit(self, coroutine: Coroutine[Any, Any, T], *, timeout: float | None) -> T:
185
+ effective_timeout = self.default_timeout if timeout is None else timeout
186
+ if effective_timeout <= 0:
187
+ coroutine.close()
188
+ raise ValueError("timeout must be positive")
189
+ with self._state_lock:
190
+ if self._closed:
191
+ coroutine.close()
192
+ raise RuntimeError("multiplexed client is closed")
193
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
194
+ try:
195
+ return future.result(timeout=effective_timeout)
196
+ except FutureTimeoutError as exc:
197
+ future.cancel()
198
+ raise TimeoutError("multiplexed client operation timed out") from exc
199
+
200
+ def _submit_unbounded(self, coroutine: Coroutine[Any, Any, T]) -> T:
201
+ with self._state_lock:
202
+ if self._closed:
203
+ coroutine.close()
204
+ raise RuntimeError("multiplexed client is closed")
205
+ future = asyncio.run_coroutine_threadsafe(coroutine, self._loop)
206
+ try:
207
+ return future.result()
208
+ except BaseException:
209
+ future.cancel()
210
+ raise
211
+
212
+ def _run_loop(self) -> None:
213
+ asyncio.set_event_loop(self._loop)
214
+ self._started.set()
215
+ try:
216
+ self._loop.run_forever()
217
+ finally:
218
+ pending = asyncio.all_tasks(self._loop)
219
+ for task in pending:
220
+ task.cancel()
221
+ if pending:
222
+ self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
223
+ self._loop.run_until_complete(self._loop.shutdown_asyncgens())
224
+ self._loop.close()
@@ -18,12 +18,16 @@ from dataclasses import dataclass, field
18
18
  from pathlib import Path
19
19
  from typing import Any, Protocol
20
20
 
21
+ from .secure_files import open_private_append, read_private_text
22
+
21
23
  DEFAULT_POLICY_FILE = Path("~/.hostbridge/policy.json").expanduser()
22
24
  LEGACY_POLICY_FILE = Path("~/.server_control_mcp/policy.json").expanduser()
23
25
  DEFAULT_AUDIT_LOG = Path("~/.hostbridge/audit.jsonl").expanduser()
24
26
  LEGACY_AUDIT_LOG = Path("~/.server_control_mcp/audit.jsonl").expanduser()
25
27
  DEFAULT_IDLE_TIMEOUT_SECONDS = 1800
26
28
  DEFAULT_REAPER_INTERVAL_SECONDS = 60
29
+ DEFAULT_AUDIT_MAX_BYTES = 10 * 1024 * 1024
30
+ DEFAULT_AUDIT_BACKUP_COUNT = 3
27
31
 
28
32
  DEFAULT_DENYLIST_PATTERNS = (
29
33
  r"\brm\s+-rf\s+/(?:\s|$)",
@@ -46,16 +50,37 @@ class _NullAudit:
46
50
 
47
51
 
48
52
  class _JsonlAudit:
49
- def __init__(self, path: Path) -> None:
53
+ def __init__(self, path: Path, *, max_bytes: int, backup_count: int) -> None:
50
54
  self._path = path
55
+ self._max_bytes = max_bytes
56
+ self._backup_count = backup_count
51
57
  self._lock = threading.Lock()
52
58
 
53
59
  def write(self, event: dict[str, Any]) -> None:
54
60
  line = json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n"
61
+ encoded_size = len(line.encode("utf-8"))
55
62
  with self._lock:
56
- self._path.parent.mkdir(parents=True, exist_ok=True)
57
- with self._path.open("a", encoding="utf-8") as handle:
63
+ current_size = self._path.stat().st_size if self._path.exists() else 0
64
+ if current_size and current_size + encoded_size > self._max_bytes:
65
+ self._rotate()
66
+ with open_private_append(self._path) as handle:
58
67
  handle.write(line)
68
+ handle.flush()
69
+
70
+ def _rotate(self) -> None:
71
+ if self._backup_count == 0:
72
+ self._path.unlink(missing_ok=True)
73
+ return
74
+ oldest = self._backup_path(self._backup_count)
75
+ oldest.unlink(missing_ok=True)
76
+ for index in range(self._backup_count - 1, 0, -1):
77
+ source = self._backup_path(index)
78
+ if source.exists():
79
+ source.replace(self._backup_path(index + 1))
80
+ self._path.replace(self._backup_path(1))
81
+
82
+ def _backup_path(self, index: int) -> Path:
83
+ return self._path.with_name(f"{self._path.name}.{index}")
59
84
 
60
85
 
61
86
  @dataclass(frozen=True, slots=True)
@@ -70,6 +95,8 @@ class PolicyConfig:
70
95
  idle_timeout_seconds: int = DEFAULT_IDLE_TIMEOUT_SECONDS
71
96
  command_denylist: tuple[re.Pattern[str], ...] = field(default_factory=tuple)
72
97
  audit_log_path: Path | None = None
98
+ audit_max_bytes: int = DEFAULT_AUDIT_MAX_BYTES
99
+ audit_backup_count: int = DEFAULT_AUDIT_BACKUP_COUNT
73
100
  enabled: bool = True
74
101
 
75
102
  @classmethod
@@ -90,7 +117,9 @@ class PolicyConfig:
90
117
  idle_timeout = int(
91
118
  env.get(
92
119
  "HOSTBRIDGE_IDLE_TIMEOUT",
93
- env.get("SERVER_CONTROL_MCP_IDLE_TIMEOUT", raw.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS)),
120
+ env.get(
121
+ "SERVER_CONTROL_MCP_IDLE_TIMEOUT", raw.get("idle_timeout_seconds", DEFAULT_IDLE_TIMEOUT_SECONDS)
122
+ ),
94
123
  )
95
124
  )
96
125
  idle_timeout = max(0, idle_timeout)
@@ -104,17 +133,43 @@ class PolicyConfig:
104
133
  if env_extra:
105
134
  patterns.extend(p for p in env_extra.split(os.pathsep) if p)
106
135
 
107
- default_audit = DEFAULT_AUDIT_LOG if DEFAULT_AUDIT_LOG.exists() or not LEGACY_AUDIT_LOG.exists() else LEGACY_AUDIT_LOG
136
+ default_audit = (
137
+ DEFAULT_AUDIT_LOG if DEFAULT_AUDIT_LOG.exists() or not LEGACY_AUDIT_LOG.exists() else LEGACY_AUDIT_LOG
138
+ )
108
139
  audit_path_str = env.get(
109
140
  "HOSTBRIDGE_AUDIT_LOG",
110
141
  env.get("SERVER_CONTROL_MCP_AUDIT_LOG", str(raw.get("audit_log_path") or default_audit)),
111
142
  )
112
143
  audit_path = Path(audit_path_str).expanduser() if audit_path_str else None
144
+ audit_max_bytes = int(
145
+ env.get(
146
+ "HOSTBRIDGE_AUDIT_MAX_BYTES",
147
+ env.get(
148
+ "SERVER_CONTROL_MCP_AUDIT_MAX_BYTES",
149
+ raw.get("audit_max_bytes", DEFAULT_AUDIT_MAX_BYTES),
150
+ ),
151
+ )
152
+ )
153
+ audit_backup_count = int(
154
+ env.get(
155
+ "HOSTBRIDGE_AUDIT_BACKUP_COUNT",
156
+ env.get(
157
+ "SERVER_CONTROL_MCP_AUDIT_BACKUP_COUNT",
158
+ raw.get("audit_backup_count", DEFAULT_AUDIT_BACKUP_COUNT),
159
+ ),
160
+ )
161
+ )
162
+ if audit_max_bytes <= 0:
163
+ raise ValueError("audit_max_bytes must be positive")
164
+ if audit_backup_count < 0:
165
+ raise ValueError("audit_backup_count must be non-negative")
113
166
 
114
167
  return cls(
115
168
  idle_timeout_seconds=idle_timeout,
116
169
  command_denylist=tuple(re.compile(pattern) for pattern in patterns),
117
170
  audit_log_path=audit_path,
171
+ audit_max_bytes=audit_max_bytes,
172
+ audit_backup_count=audit_backup_count,
118
173
  enabled=True,
119
174
  )
120
175
 
@@ -130,7 +185,9 @@ class PolicyConfig:
130
185
 
131
186
 
132
187
  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 (
188
+ if (
189
+ os.environ.get("HOSTBRIDGE_DISABLE_POLICY", "") or os.environ.get("SERVER_CONTROL_MCP_DISABLE_POLICY", "")
190
+ ).lower() in (
134
191
  "1",
135
192
  "true",
136
193
  "yes",
@@ -141,24 +198,39 @@ def load_policy(config_path: Path | str | None = None) -> PolicyConfig:
141
198
  config_path
142
199
  or os.environ.get("HOSTBRIDGE_POLICY_FILE")
143
200
  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)
201
+ or (
202
+ DEFAULT_POLICY_FILE
203
+ if DEFAULT_POLICY_FILE.exists() or not LEGACY_POLICY_FILE.exists()
204
+ else LEGACY_POLICY_FILE
205
+ )
145
206
  ).expanduser()
146
207
 
147
208
  raw: dict[str, Any] = {}
148
- if path.exists():
209
+ try:
210
+ policy_text = read_private_text(path)
211
+ except FileNotFoundError:
212
+ policy_text = None
213
+ except OSError as exc:
214
+ raise ValueError(f"failed to read HostBridge policy file {path}") from exc
215
+ if policy_text is not None:
149
216
  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 = {}
217
+ data = json.loads(policy_text)
218
+ except json.JSONDecodeError as exc:
219
+ raise ValueError(f"HostBridge policy file {path} is not valid JSON") from exc
220
+ if not isinstance(data, dict):
221
+ raise ValueError(f"HostBridge policy file {path} must contain a JSON object")
222
+ raw = data
155
223
  return PolicyConfig.from_dict(raw)
156
224
 
157
225
 
158
226
  def build_audit_sink(policy: PolicyConfig) -> AuditSink:
159
227
  if not policy.enabled or policy.audit_log_path is None:
160
228
  return _NullAudit()
161
- return _JsonlAudit(policy.audit_log_path)
229
+ return _JsonlAudit(
230
+ policy.audit_log_path,
231
+ max_bytes=policy.audit_max_bytes,
232
+ backup_count=policy.audit_backup_count,
233
+ )
162
234
 
163
235
 
164
236
  def audit_event(