@neoline/hostbridge 2.0.2 → 2.0.4

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.
@@ -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(
@@ -66,6 +66,21 @@ def encode_frame(frame: BinaryFrame) -> bytes:
66
66
  def decode_frame(data: bytes) -> tuple[BinaryFrame, bytes]:
67
67
  if not isinstance(data, bytes):
68
68
  raise ProtocolError("frame data must be bytes")
69
+ sequence, flags, stream_id_length, payload_length = _decode_header(data)
70
+ frame_length = _FRAME_HEADER.size + stream_id_length + payload_length
71
+ if len(data) < frame_length:
72
+ raise ProtocolError("incomplete frame payload")
73
+ stream_id_start = _FRAME_HEADER.size
74
+ payload_start = stream_id_start + stream_id_length
75
+ try:
76
+ stream_id = data[stream_id_start:payload_start].decode("utf-8")
77
+ except UnicodeDecodeError as exc:
78
+ raise ProtocolError("stream_id must be valid UTF-8") from exc
79
+ payload = data[payload_start:frame_length]
80
+ return BinaryFrame(_required_text(stream_id, "stream_id"), sequence, flags, payload), data[frame_length:]
81
+
82
+
83
+ def _decode_header(data: bytes) -> tuple[int, FrameFlags, int, int]:
69
84
  if len(data) < _FRAME_HEADER.size:
70
85
  raise ProtocolError("incomplete frame header")
71
86
  magic, protocol, raw_flags, reserved, sequence, stream_id_length, payload_length = _FRAME_HEADER.unpack_from(data)
@@ -79,23 +94,13 @@ def decode_frame(data: bytes) -> tuple[BinaryFrame, bytes]:
79
94
  raise ProtocolError(f"stream_id exceeds {MAX_STREAM_ID_BYTES} bytes")
80
95
  if payload_length > MAX_FRAME_BYTES:
81
96
  raise ProtocolError(f"frame payload exceeds {MAX_FRAME_BYTES} bytes")
82
- frame_length = _FRAME_HEADER.size + stream_id_length + payload_length
83
- if len(data) < frame_length:
84
- raise ProtocolError("incomplete frame payload")
85
- stream_id_start = _FRAME_HEADER.size
86
- payload_start = stream_id_start + stream_id_length
87
- try:
88
- stream_id = data[stream_id_start:payload_start].decode("utf-8")
89
- except UnicodeDecodeError as exc:
90
- raise ProtocolError("stream_id must be valid UTF-8") from exc
91
97
  try:
92
98
  flags = FrameFlags(raw_flags)
93
99
  except ValueError as exc:
94
100
  raise ProtocolError("flags contain an unsupported frame flag") from exc
95
101
  if not flags or int(flags) & ~int(FrameFlags.DATA | FrameFlags.EOF | FrameFlags.CANCEL):
96
102
  raise ProtocolError("flags contain an unsupported frame flag")
97
- payload = data[payload_start:frame_length]
98
- return BinaryFrame(_required_text(stream_id, "stream_id"), sequence, flags, payload), data[frame_length:]
103
+ return sequence, flags, stream_id_length, payload_length
99
104
 
100
105
 
101
106
  def _read_exact(stream: BinaryIO, size: int) -> bytes:
@@ -110,9 +115,7 @@ def _read_exact(stream: BinaryIO, size: int) -> bytes:
110
115
 
111
116
  def read_frame(stream: BinaryIO) -> BinaryFrame:
112
117
  header = _read_exact(stream, _FRAME_HEADER.size)
113
- _, _, _, _, _, stream_id_length, payload_length = _FRAME_HEADER.unpack(header)
114
- if stream_id_length > MAX_STREAM_ID_BYTES or payload_length > MAX_FRAME_BYTES:
115
- decode_frame(header)
118
+ _, _, stream_id_length, payload_length = _decode_header(header)
116
119
  body = _read_exact(stream, stream_id_length + payload_length)
117
120
  frame, remainder = decode_frame(header + body)
118
121
  if remainder:
@@ -169,9 +172,7 @@ class FrameSequenceValidator:
169
172
  def accept(self, frame: BinaryFrame) -> None:
170
173
  expected = self._next_by_stream.get(frame.stream_id, 0)
171
174
  if frame.sequence != expected:
172
- raise ProtocolError(
173
- f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}"
174
- )
175
+ raise ProtocolError(f"stream {frame.stream_id!r} expected sequence {expected}, received {frame.sequence}")
175
176
  self._next_by_stream[frame.stream_id] = expected + 1
176
177
 
177
178
 
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+ from collections.abc import Iterable
5
+
6
+ REMOTE_TASK_LOG_MAX_BYTES = 1024 * 1024
7
+ REMOTE_TASK_READ_CHUNK_BYTES = 64 * 1024
8
+
9
+ REMOTE_TASK_AGENT = r"""
10
+ import os
11
+ import pathlib
12
+ import subprocess
13
+ import sys
14
+
15
+ command, job_dir_text, log_limit_text = sys.argv[1], sys.argv[2], sys.argv[3]
16
+ job_dir = pathlib.Path(job_dir_text)
17
+ log_limit = int(log_limit_text)
18
+
19
+
20
+ def write_atomic(name, value):
21
+ temporary = job_dir / ("." + name + ".tmp")
22
+ temporary.write_text(str(value) + "\n", encoding="ascii")
23
+ os.replace(temporary, job_dir / name)
24
+
25
+
26
+ os.setsid()
27
+ write_atomic("pid", os.getpid())
28
+ with (job_dir / "stdout.log").open("w+b", buffering=0) as output:
29
+ process = subprocess.Popen(
30
+ ["/bin/sh", "-lc", command],
31
+ stdin=subprocess.DEVNULL,
32
+ stdout=subprocess.PIPE,
33
+ stderr=subprocess.STDOUT,
34
+ )
35
+ current_size = 0
36
+ while True:
37
+ chunk = process.stdout.read(65536)
38
+ if not chunk:
39
+ break
40
+ if len(chunk) >= log_limit:
41
+ retained = chunk[-log_limit:]
42
+ elif current_size + len(chunk) > log_limit:
43
+ keep = log_limit - len(chunk)
44
+ output.seek(current_size - keep)
45
+ retained = output.read(keep) + chunk
46
+ else:
47
+ output.seek(current_size)
48
+ output.write(chunk)
49
+ current_size += len(chunk)
50
+ continue
51
+ output.seek(0)
52
+ output.write(retained)
53
+ output.truncate()
54
+ current_size = len(retained)
55
+ returncode = process.wait()
56
+ status = returncode if returncode >= 0 else 128 - returncode
57
+ write_atomic("exit_code", status)
58
+ """
59
+
60
+
61
+ def remote_job_dir(task_id: str) -> str:
62
+ return f"$HOME/.hostbridge/jobs/{task_id}"
63
+
64
+
65
+ def remote_job_dir_assignment(task_id: str) -> str:
66
+ relative_path = shlex.quote(f".hostbridge/jobs/{task_id}")
67
+ return f'job_dir="$HOME"/{relative_path}'
68
+
69
+
70
+ def build_remote_task_command(command: str, task_id: str) -> str:
71
+ supervisor = (
72
+ f'python3 -u -c {shlex.quote(REMOTE_TASK_AGENT)} {shlex.quote(command)} "$job_dir" {REMOTE_TASK_LOG_MAX_BYTES}'
73
+ )
74
+ return (
75
+ 'umask 077; [ -n "${HOME:-}" ] || exit 1; '
76
+ f'{remote_job_dir_assignment(task_id)}; mkdir -p -- "$job_dir" || exit $?; '
77
+ 'chmod 700 "$HOME/.hostbridge" "$HOME/.hostbridge/jobs" "$job_dir" || exit $?; '
78
+ f"nohup {supervisor} </dev/null >/dev/null 2>&1 & launcher=$!; "
79
+ 'attempt=0; while [ "$attempt" -lt 100 ]; do '
80
+ 'if [ -s "$job_dir/pid" ]; then cat "$job_dir/pid"; exit 0; fi; '
81
+ 'if ! kill -0 "$launcher" 2>/dev/null; then break; fi; '
82
+ "attempt=$((attempt + 1)); sleep 0.05; done; exit 1"
83
+ )
84
+
85
+
86
+ def build_remote_tasks_cleanup_command(tasks: Iterable[tuple[str, int | None]]) -> str:
87
+ cleanup_steps = ["umask 077", '[ -n "${HOME:-}" ] || exit 1']
88
+ for task_id, recorded_pid in tasks:
89
+ fallback_pid = str(recorded_pid) if recorded_pid is not None else ""
90
+ cleanup_steps.extend(
91
+ (
92
+ remote_job_dir_assignment(task_id),
93
+ f'pid=$(cat "$job_dir/pid" 2>/dev/null || printf %s {shlex.quote(fallback_pid)})',
94
+ 'if [ ! -f "$job_dir/exit_code" ] && [ -n "$pid" ] && kill -0 -- -"$pid" 2>/dev/null; then '
95
+ 'kill -TERM -- -"$pid" 2>/dev/null || true; attempt=0; '
96
+ 'while kill -0 -- -"$pid" 2>/dev/null && [ "$attempt" -lt 20 ]; do '
97
+ "attempt=$((attempt + 1)); sleep 0.05; done; "
98
+ 'kill -KILL -- -"$pid" 2>/dev/null || true; fi',
99
+ 'rm -rf -- "$job_dir" || exit $?',
100
+ )
101
+ )
102
+ return "; ".join(cleanup_steps)
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import shlex
4
+
5
+ REMOTE_TUNNEL_AGENT = r"""
6
+ import base64
7
+ import os
8
+ import socket
9
+ import sys
10
+ import termios
11
+ import threading
12
+
13
+ host, port, prefix = sys.argv[1], int(sys.argv[2]), sys.argv[3]
14
+ prefix_bytes = prefix.encode("ascii")
15
+ write_lock = threading.Lock()
16
+ downstream_sequence = 0
17
+ upstream_sequence = 0
18
+ terminal_attributes = None
19
+ connection = None
20
+ receiver_error = []
21
+
22
+
23
+ def emit(kind, payload=b"", status=None):
24
+ global downstream_sequence
25
+ status_text = "-" if status is None else str(status)
26
+ encoded = base64.b64encode(payload).decode("ascii")
27
+ with write_lock:
28
+ line = "%s:V1:D:%d:%s:%s:%s" % (
29
+ prefix,
30
+ downstream_sequence,
31
+ kind,
32
+ status_text,
33
+ encoded,
34
+ )
35
+ downstream_sequence += 1
36
+ print(line, flush=True)
37
+
38
+
39
+ def disable_echo():
40
+ global terminal_attributes
41
+ if not os.isatty(sys.stdin.fileno()):
42
+ return
43
+ terminal_attributes = termios.tcgetattr(sys.stdin.fileno())
44
+ updated = list(terminal_attributes)
45
+ updated[3] &= ~termios.ECHO
46
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, updated)
47
+
48
+
49
+ def restore_terminal():
50
+ if terminal_attributes is not None:
51
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSANOW, terminal_attributes)
52
+
53
+
54
+ def decode_upstream(raw_line):
55
+ global upstream_sequence
56
+ line = raw_line.rstrip(b"\r\n")
57
+ marker = prefix_bytes + b":V"
58
+ offset = line.find(marker)
59
+ if offset < 0:
60
+ return None
61
+ fields = line[offset:].split(b":", 6)
62
+ if len(fields) != 7:
63
+ raise ValueError("malformed upstream frame")
64
+ frame_prefix, version, direction, sequence, kind, status, payload = fields
65
+ if frame_prefix != prefix_bytes or version != b"V1" or direction != b"U":
66
+ raise ValueError("invalid upstream frame header")
67
+ if int(sequence) != upstream_sequence:
68
+ raise ValueError("unexpected upstream frame sequence")
69
+ upstream_sequence += 1
70
+ if status != b"-":
71
+ raise ValueError("upstream frame must not contain status")
72
+ kind_text = kind.decode("ascii")
73
+ if kind_text not in {"DATA", "EOF"}:
74
+ raise ValueError("invalid upstream frame kind")
75
+ decoded = base64.b64decode(payload, validate=True)
76
+ if kind_text == "EOF" and decoded:
77
+ raise ValueError("upstream EOF must not contain payload")
78
+ return kind_text, decoded
79
+
80
+
81
+ def receive():
82
+ try:
83
+ while True:
84
+ data = connection.recv(16 * 1024)
85
+ if not data:
86
+ emit("EOF")
87
+ return
88
+ emit("DATA", data)
89
+ except Exception as exc:
90
+ receiver_error.append(exc)
91
+ emit("ERROR", str(exc).encode("utf-8", errors="replace"))
92
+
93
+
94
+ status = 0
95
+ try:
96
+ disable_echo()
97
+ connection = socket.create_connection((host, port), timeout=15)
98
+ connection.settimeout(None)
99
+ emit("READY")
100
+ receiver = threading.Thread(target=receive, daemon=True)
101
+ receiver.start()
102
+ saw_upstream_eof = False
103
+ for raw_line in sys.stdin.buffer:
104
+ frame = decode_upstream(raw_line)
105
+ if frame is None:
106
+ continue
107
+ kind, payload = frame
108
+ if kind == "DATA":
109
+ connection.sendall(payload)
110
+ else:
111
+ connection.shutdown(socket.SHUT_WR)
112
+ saw_upstream_eof = True
113
+ break
114
+ if not saw_upstream_eof:
115
+ raise EOFError("upstream tunnel closed without EOF frame")
116
+ receiver.join()
117
+ if receiver_error:
118
+ status = 1
119
+ except Exception as exc:
120
+ status = 1
121
+ emit("ERROR", str(exc).encode("utf-8", errors="replace"))
122
+ finally:
123
+ if connection is not None:
124
+ try:
125
+ connection.close()
126
+ except Exception:
127
+ status = 1
128
+ try:
129
+ restore_terminal()
130
+ except Exception as exc:
131
+ status = 1
132
+ emit("ERROR", str(exc).encode("utf-8", errors="replace"))
133
+ emit("CLOSED", status=status)
134
+
135
+ raise SystemExit(status)
136
+ """
137
+
138
+
139
+ def build_remote_tunnel_command(dest_host: str, dest_port: int, prefix: str) -> bytes:
140
+ command = (
141
+ f"python3 -u -c {shlex.quote(REMOTE_TUNNEL_AGENT)} {shlex.quote(dest_host)} {dest_port} {shlex.quote(prefix)}\n"
142
+ )
143
+ return command.encode("utf-8")
@@ -9,6 +9,8 @@ import tempfile
9
9
  from dataclasses import dataclass
10
10
  from pathlib import Path
11
11
 
12
+ from .secure_files import ensure_private_directory
13
+
12
14
 
13
15
  @dataclass(frozen=True, slots=True)
14
16
  class RuntimePaths:
@@ -18,8 +20,7 @@ class RuntimePaths:
18
20
  log: Path
19
21
 
20
22
  def ensure_directory(self) -> None:
21
- self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
22
- self.directory.chmod(0o700)
23
+ ensure_private_directory(self.directory)
23
24
 
24
25
 
25
26
  def _default_runtime_dir() -> Path:
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from contextlib import suppress
5
4
  from pathlib import Path
6
5
  from typing import Any
7
6
 
8
7
  from cryptography.fernet import Fernet, InvalidToken
9
8
 
9
+ from .secure_files import create_private_file, validate_private_file
10
+
10
11
  DEFAULT_KEY_FILE = Path("~/.hostbridge/key").expanduser()
11
12
  LEGACY_KEY_FILE = Path("~/.server_control_mcp/key").expanduser()
12
13
  SCHEME = "fernet.v1"
@@ -24,13 +25,12 @@ def key_file_path() -> Path:
24
25
  def _load_or_create_key() -> bytes:
25
26
  path = key_file_path()
26
27
  if path.exists():
28
+ validate_private_file(path)
27
29
  return path.read_bytes().strip()
28
- path.parent.mkdir(parents=True, exist_ok=True)
29
30
  key = Fernet.generate_key()
30
- path.write_bytes(key + b"\n")
31
- with suppress(OSError):
32
- path.chmod(0o600)
33
- return key
31
+ if create_private_file(path, key + b"\n"):
32
+ return key
33
+ return path.read_bytes().strip()
34
34
 
35
35
 
36
36
  def encrypt_secret(value: str) -> dict[str, str]:
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import os
5
+ import stat
6
+ import tempfile
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+
10
+
11
+ def ensure_private_directory(path: Path) -> None:
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ with suppress(FileExistsError):
14
+ os.mkdir(path, 0o700)
15
+ metadata = path.lstat()
16
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
17
+ raise RuntimeError(f"secure directory is not a real directory: {path}")
18
+ if metadata.st_uid != os.getuid():
19
+ raise PermissionError(f"secure directory is not owned by the current user: {path}")
20
+ path.chmod(0o700)
21
+
22
+
23
+ def validate_private_file(path: Path) -> None:
24
+ metadata = path.lstat()
25
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
26
+ raise RuntimeError(f"private file is not a regular file: {path}")
27
+ if metadata.st_uid != os.getuid():
28
+ raise PermissionError(f"private file is not owned by the current user: {path}")
29
+ path.chmod(0o600)
30
+
31
+
32
+ def read_private_text(path: Path) -> str:
33
+ nofollow = getattr(os, "O_NOFOLLOW", None)
34
+ if nofollow is None:
35
+ raise RuntimeError("secure private file reads require O_NOFOLLOW")
36
+ flags = os.O_RDONLY | nofollow
37
+ if hasattr(os, "O_CLOEXEC"):
38
+ flags |= os.O_CLOEXEC
39
+ try:
40
+ descriptor = os.open(path, flags)
41
+ except OSError as exc:
42
+ if exc.errno == errno.ELOOP:
43
+ raise RuntimeError(f"private file is not a regular file: {path}") from exc
44
+ raise
45
+ try:
46
+ metadata = os.fstat(descriptor)
47
+ if not stat.S_ISREG(metadata.st_mode):
48
+ raise RuntimeError(f"private file is not a regular file: {path}")
49
+ if metadata.st_uid != os.getuid():
50
+ raise PermissionError(f"private file is not owned by the current user: {path}")
51
+ os.fchmod(descriptor, 0o600)
52
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
53
+ descriptor = -1
54
+ return handle.read()
55
+ finally:
56
+ if descriptor >= 0:
57
+ os.close(descriptor)
58
+
59
+
60
+ def create_private_file(path: Path, data: bytes) -> bool:
61
+ ensure_private_directory(path.parent)
62
+ descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
63
+ temporary = Path(name)
64
+ try:
65
+ os.fchmod(descriptor, 0o600)
66
+ _write_all(descriptor, data)
67
+ os.fsync(descriptor)
68
+ os.close(descriptor)
69
+ descriptor = -1
70
+ try:
71
+ os.link(temporary, path)
72
+ except FileExistsError:
73
+ validate_private_file(path)
74
+ return False
75
+ validate_private_file(path)
76
+ return True
77
+ finally:
78
+ if descriptor >= 0:
79
+ os.close(descriptor)
80
+ temporary.unlink(missing_ok=True)
81
+
82
+
83
+ def replace_private_file(path: Path, data: bytes) -> None:
84
+ ensure_private_directory(path.parent)
85
+ descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
86
+ temporary = Path(name)
87
+ try:
88
+ os.fchmod(descriptor, 0o600)
89
+ _write_all(descriptor, data)
90
+ os.fsync(descriptor)
91
+ os.close(descriptor)
92
+ descriptor = -1
93
+ temporary.replace(path)
94
+ validate_private_file(path)
95
+ finally:
96
+ if descriptor >= 0:
97
+ os.close(descriptor)
98
+ temporary.unlink(missing_ok=True)
99
+
100
+
101
+ def open_private_append(path: Path):
102
+ ensure_private_directory(path.parent)
103
+ flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
104
+ if hasattr(os, "O_NOFOLLOW"):
105
+ flags |= os.O_NOFOLLOW
106
+ descriptor = os.open(path, flags, 0o600)
107
+ metadata = os.fstat(descriptor)
108
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
109
+ os.close(descriptor)
110
+ raise PermissionError(f"private log is not owned by the current user: {path}")
111
+ os.fchmod(descriptor, 0o600)
112
+ return os.fdopen(descriptor, "a", encoding="utf-8")
113
+
114
+
115
+ def _write_all(descriptor: int, data: bytes) -> None:
116
+ remaining = memoryview(data)
117
+ while remaining:
118
+ written = os.write(descriptor, remaining)
119
+ if written <= 0:
120
+ raise OSError("private file write made no progress")
121
+ remaining = remaining[written:]