moonbridge 0.6.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
moonbridge/__init__.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.6.0"
5
+ __version__ = "0.8.0"
6
6
 
7
7
  from .server import main, run, server
8
8
 
@@ -50,6 +50,7 @@ class CodexAdapter:
50
50
  install_hint="See https://github.com/openai/codex",
51
51
  supports_thinking=False,
52
52
  known_models=(
53
+ "gpt-5.3-codex",
53
54
  "gpt-5.2-codex",
54
55
  "gpt-5.1-codex",
55
56
  "gpt-5.1-codex-mini",
moonbridge/sandbox.py ADDED
@@ -0,0 +1,252 @@
1
+ """Copy-on-run sandbox for agent execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ import time
10
+ from collections.abc import Callable, Iterator
11
+ from dataclasses import dataclass, replace
12
+ from pathlib import Path
13
+
14
+ from moonbridge.adapters.base import AgentResult
15
+
16
+ SANDBOX_IGNORE_DIRS = {
17
+ ".git",
18
+ ".venv",
19
+ ".tox",
20
+ "__pycache__",
21
+ ".mypy_cache",
22
+ ".pytest_cache",
23
+ ".ruff_cache",
24
+ "node_modules",
25
+ "dist",
26
+ "build",
27
+ }
28
+ SANDBOX_IGNORE_FILES = {".DS_Store"}
29
+ MAX_COPY_BYTES = 500 * 1024 * 1024
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SandboxResult:
34
+ diff: str
35
+ summary: dict[str, int]
36
+ truncated: bool
37
+ sandbox_path: str | None
38
+
39
+
40
+ def _should_ignore(name: str) -> bool:
41
+ if name in SANDBOX_IGNORE_DIRS:
42
+ return True
43
+ if name in SANDBOX_IGNORE_FILES:
44
+ return True
45
+ return name.endswith((".pyc", ".pyo"))
46
+
47
+
48
+ def _ignore_names(_dirpath: str, names: list[str]) -> set[str]:
49
+ return {name for name in names if _should_ignore(name)}
50
+
51
+
52
+ def _filtered_walk(root: str) -> Iterator[tuple[str, list[str], list[str]]]:
53
+ for dirpath, dirnames, filenames in os.walk(root):
54
+ dirnames[:] = [d for d in dirnames if not _should_ignore(d)]
55
+ filenames = [f for f in filenames if not _should_ignore(f)]
56
+ yield dirpath, dirnames, filenames
57
+
58
+
59
+ def _collect_files(root: str) -> set[str]:
60
+ files: set[str] = set()
61
+ for dirpath, _dirnames, filenames in _filtered_walk(root):
62
+ rel_dir = os.path.relpath(dirpath, root)
63
+ for filename in filenames:
64
+ rel_path = filename if rel_dir == "." else os.path.join(rel_dir, filename)
65
+ files.add(rel_path)
66
+ return files
67
+
68
+
69
+ def _read_text(path: str) -> str | None:
70
+ data = Path(path).read_bytes()
71
+ try:
72
+ return data.decode("utf-8")
73
+ except UnicodeDecodeError:
74
+ return None
75
+
76
+
77
+ def _diff_trees(
78
+ original: str,
79
+ sandbox: str,
80
+ max_bytes: int,
81
+ ) -> tuple[str, dict[str, int], bool]:
82
+ original_files = _collect_files(original)
83
+ sandbox_files = _collect_files(sandbox)
84
+ all_files = sorted(original_files | sandbox_files)
85
+ diff_chunks: list[str] = []
86
+ size = 0
87
+ truncated = False
88
+ summary = {"added": 0, "modified": 0, "deleted": 0, "binary": 0}
89
+
90
+ def append_chunk(chunk: str) -> None:
91
+ nonlocal size, truncated
92
+ if truncated or not chunk:
93
+ return
94
+ remaining = max_bytes - size
95
+ if remaining <= 0:
96
+ truncated = True
97
+ return
98
+ if len(chunk) > remaining:
99
+ diff_chunks.append(chunk[:remaining])
100
+ truncated = True
101
+ size = max_bytes
102
+ return
103
+ diff_chunks.append(chunk)
104
+ size += len(chunk)
105
+
106
+ for rel_path in all_files:
107
+ original_path = os.path.join(original, rel_path)
108
+ sandbox_path = os.path.join(sandbox, rel_path)
109
+ original_exists = os.path.exists(original_path)
110
+ sandbox_exists = os.path.exists(sandbox_path)
111
+
112
+ if not original_exists and sandbox_exists:
113
+ summary["added"] += 1
114
+ sandbox_text = _read_text(sandbox_path)
115
+ if sandbox_text is None:
116
+ summary["binary"] += 1
117
+ append_chunk(f"Binary files /dev/null and b/{rel_path} differ\n")
118
+ continue
119
+ diff = difflib.unified_diff(
120
+ [],
121
+ sandbox_text.splitlines(keepends=True),
122
+ fromfile="/dev/null",
123
+ tofile=f"b/{rel_path}",
124
+ )
125
+ append_chunk("".join(diff))
126
+ continue
127
+
128
+ if original_exists and not sandbox_exists:
129
+ summary["deleted"] += 1
130
+ original_text = _read_text(original_path)
131
+ if original_text is None:
132
+ summary["binary"] += 1
133
+ append_chunk(f"Binary files a/{rel_path} and /dev/null differ\n")
134
+ continue
135
+ diff = difflib.unified_diff(
136
+ original_text.splitlines(keepends=True),
137
+ [],
138
+ fromfile=f"a/{rel_path}",
139
+ tofile="/dev/null",
140
+ )
141
+ append_chunk("".join(diff))
142
+ continue
143
+
144
+ if not original_exists or not sandbox_exists:
145
+ continue
146
+
147
+ original_bytes = Path(original_path).read_bytes()
148
+ sandbox_bytes = Path(sandbox_path).read_bytes()
149
+ if original_bytes == sandbox_bytes:
150
+ continue
151
+
152
+ original_text = None
153
+ sandbox_text = None
154
+ try:
155
+ original_text = original_bytes.decode("utf-8")
156
+ sandbox_text = sandbox_bytes.decode("utf-8")
157
+ except UnicodeDecodeError:
158
+ summary["binary"] += 1
159
+ append_chunk(f"Binary files a/{rel_path} and b/{rel_path} differ\n")
160
+ continue
161
+
162
+ summary["modified"] += 1
163
+ diff = difflib.unified_diff(
164
+ original_text.splitlines(keepends=True),
165
+ sandbox_text.splitlines(keepends=True),
166
+ fromfile=f"a/{rel_path}",
167
+ tofile=f"b/{rel_path}",
168
+ )
169
+ append_chunk("".join(diff))
170
+
171
+ if truncated:
172
+ diff_chunks.append("\n... diff truncated ...\n")
173
+ return ("".join(diff_chunks), summary, truncated)
174
+
175
+
176
+ def _estimate_copy_size(root: str, max_bytes: int) -> int:
177
+ total = 0
178
+ for dirpath, _dirnames, filenames in _filtered_walk(root):
179
+ for filename in filenames:
180
+ path = os.path.join(dirpath, filename)
181
+ total += os.path.getsize(path)
182
+ if total > max_bytes:
183
+ return total
184
+ return total
185
+
186
+
187
+ def _agent_index(fn: Callable[[str], AgentResult]) -> int:
188
+ value = getattr(fn, "agent_index", 0)
189
+ return value if isinstance(value, int) else 0
190
+
191
+
192
+ def run_sandboxed(
193
+ fn: Callable[[str], AgentResult],
194
+ cwd: str,
195
+ *,
196
+ max_diff_bytes: int = 500_000,
197
+ max_copy_bytes: int = MAX_COPY_BYTES,
198
+ keep: bool = False,
199
+ ) -> tuple[AgentResult, SandboxResult | None]:
200
+ """Run fn in a copy of cwd. Returns (agent_result, sandbox_result).
201
+
202
+ On sandbox infrastructure error, returns (error_result, None).
203
+ """
204
+ start = time.monotonic()
205
+ sandbox_root: str | None = None
206
+ agent_index = _agent_index(fn)
207
+
208
+ def error_result(reason: str) -> AgentResult:
209
+ duration_ms = int((time.monotonic() - start) * 1000)
210
+ return AgentResult(
211
+ status="error",
212
+ output="",
213
+ stderr=f"sandbox error: {reason}",
214
+ returncode=-1,
215
+ duration_ms=duration_ms,
216
+ agent_index=agent_index,
217
+ )
218
+
219
+ try:
220
+ total_bytes = _estimate_copy_size(cwd, max_copy_bytes)
221
+ if total_bytes > max_copy_bytes:
222
+ return error_result(
223
+ f"copy size {total_bytes} exceeds max {max_copy_bytes}"
224
+ ), None
225
+
226
+ sandbox_root = tempfile.mkdtemp(prefix="moonbridge-sandbox-")
227
+ sandbox_cwd = os.path.join(sandbox_root, "workspace")
228
+ shutil.copytree(cwd, sandbox_cwd, symlinks=False, ignore=_ignore_names)
229
+
230
+ result = fn(sandbox_cwd)
231
+
232
+ try:
233
+ diff, summary, truncated = _diff_trees(cwd, sandbox_cwd, max_diff_bytes)
234
+ sandbox_result = SandboxResult(
235
+ diff=diff,
236
+ summary=summary,
237
+ truncated=truncated,
238
+ sandbox_path=sandbox_root if keep else None,
239
+ )
240
+ return result, sandbox_result
241
+ except Exception as exc:
242
+ raw = dict(result.raw or {})
243
+ sandbox_payload: dict[str, object] = {"enabled": True, "error": str(exc)}
244
+ if keep:
245
+ sandbox_payload["path"] = sandbox_root
246
+ raw["sandbox"] = sandbox_payload
247
+ return replace(result, raw=raw), None
248
+ except Exception as exc:
249
+ return error_result(str(exc)), None
250
+ finally:
251
+ if not keep and sandbox_root:
252
+ shutil.rmtree(sandbox_root, ignore_errors=True)
moonbridge/server.py CHANGED
@@ -11,6 +11,7 @@ import signal
11
11
  import sys
12
12
  import time
13
13
  import weakref
14
+ from dataclasses import replace
14
15
  from subprocess import PIPE, Popen, TimeoutExpired
15
16
  from typing import Any
16
17
 
@@ -36,6 +37,17 @@ ALLOWED_DIRS = [
36
37
  if path
37
38
  ]
38
39
  MAX_PROMPT_LENGTH = 100_000
40
+ _SANDBOX_ENV = os.environ.get("MOONBRIDGE_SANDBOX", "").strip().lower()
41
+ SANDBOX_MODE = _SANDBOX_ENV in {"1", "true", "yes", "copy"}
42
+ SANDBOX_KEEP = os.environ.get("MOONBRIDGE_SANDBOX_KEEP", "").strip().lower() in {
43
+ "1",
44
+ "true",
45
+ "yes",
46
+ }
47
+ SANDBOX_MAX_DIFF_BYTES = int(os.environ.get("MOONBRIDGE_SANDBOX_MAX_DIFF", "500000"))
48
+ SANDBOX_MAX_COPY_BYTES = int(
49
+ os.environ.get("MOONBRIDGE_SANDBOX_MAX_COPY", str(500 * 1024 * 1024))
50
+ )
39
51
 
40
52
  _active_processes: set[weakref.ref[Popen[str]]] = set()
41
53
 
@@ -194,6 +206,53 @@ def _auth_error(stderr: str | None, adapter: CLIAdapter) -> bool:
194
206
  return any(pattern in lowered for pattern in adapter.config.auth_patterns)
195
207
 
196
208
 
209
+ def _run_cli_sandboxed(
210
+ adapter: CLIAdapter,
211
+ prompt: str,
212
+ thinking: bool,
213
+ cwd: str,
214
+ timeout_seconds: int,
215
+ agent_index: int,
216
+ model: str | None = None,
217
+ reasoning_effort: str | None = None,
218
+ ) -> AgentResult:
219
+ from moonbridge.sandbox import run_sandboxed
220
+
221
+ def run_agent(sandbox_cwd: str) -> AgentResult:
222
+ return _run_cli_sync(
223
+ adapter,
224
+ prompt,
225
+ thinking,
226
+ sandbox_cwd,
227
+ timeout_seconds,
228
+ agent_index,
229
+ model,
230
+ reasoning_effort,
231
+ )
232
+
233
+ run_agent.agent_index = agent_index # type: ignore[attr-defined]
234
+
235
+ result, sandbox_result = run_sandboxed(
236
+ run_agent,
237
+ cwd,
238
+ max_diff_bytes=SANDBOX_MAX_DIFF_BYTES,
239
+ max_copy_bytes=SANDBOX_MAX_COPY_BYTES,
240
+ keep=SANDBOX_KEEP,
241
+ )
242
+ if sandbox_result:
243
+ raw = dict(result.raw or {})
244
+ raw["sandbox"] = {
245
+ "enabled": True,
246
+ "summary": sandbox_result.summary,
247
+ "diff": sandbox_result.diff,
248
+ "truncated": sandbox_result.truncated,
249
+ }
250
+ if sandbox_result.sandbox_path:
251
+ raw["sandbox"]["path"] = sandbox_result.sandbox_path
252
+ return replace(result, raw=raw)
253
+ return result
254
+
255
+
197
256
  def _run_cli_sync(
198
257
  adapter: CLIAdapter,
199
258
  prompt: str,
@@ -304,6 +363,39 @@ def _run_cli_sync(
304
363
  _untrack_process(proc)
305
364
 
306
365
 
366
+ def _run_cli(
367
+ adapter: CLIAdapter,
368
+ prompt: str,
369
+ thinking: bool,
370
+ cwd: str,
371
+ timeout_seconds: int,
372
+ agent_index: int,
373
+ model: str | None = None,
374
+ reasoning_effort: str | None = None,
375
+ ) -> AgentResult:
376
+ if SANDBOX_MODE:
377
+ return _run_cli_sandboxed(
378
+ adapter,
379
+ prompt,
380
+ thinking,
381
+ cwd,
382
+ timeout_seconds,
383
+ agent_index,
384
+ model,
385
+ reasoning_effort,
386
+ )
387
+ return _run_cli_sync(
388
+ adapter,
389
+ prompt,
390
+ thinking,
391
+ cwd,
392
+ timeout_seconds,
393
+ agent_index,
394
+ model,
395
+ reasoning_effort,
396
+ )
397
+
398
+
307
399
  def _json_text(payload: Any) -> list[TextContent]:
308
400
  return [TextContent(type="text", text=json.dumps(payload, ensure_ascii=True))]
309
401
 
@@ -378,7 +470,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
378
470
  try:
379
471
  result = await loop.run_in_executor(
380
472
  None,
381
- _run_cli_sync,
473
+ _run_cli,
382
474
  adapter,
383
475
  prompt,
384
476
  thinking,
@@ -416,7 +508,7 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
416
508
  tasks.append(
417
509
  loop.run_in_executor(
418
510
  None,
419
- _run_cli_sync,
511
+ _run_cli,
420
512
  adapter,
421
513
  prompt,
422
514
  thinking,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moonbridge
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: MCP server for spawning AI coding agents (Kimi, Codex, and more)
5
5
  Project-URL: Homepage, https://github.com/misty-step/moonbridge
6
6
  Project-URL: Repository, https://github.com/misty-step/moonbridge
@@ -151,6 +151,7 @@ All tools return JSON with these fields:
151
151
  | `duration_ms` | int | Execution time in milliseconds |
152
152
  | `agent_index` | int | Agent index (0 for single, 0-N for parallel) |
153
153
  | `message` | string? | Human-readable error context (when applicable) |
154
+ | `raw` | object? | Optional structured metadata (e.g., sandbox diff) |
154
155
 
155
156
  ## Configuration
156
157
 
@@ -163,6 +164,10 @@ All tools return JSON with these fields:
163
164
  | `MOONBRIDGE_MAX_AGENTS` | Maximum parallel agents |
164
165
  | `MOONBRIDGE_ALLOWED_DIRS` | Colon-separated allowlist of working directories |
165
166
  | `MOONBRIDGE_STRICT` | Set to `1` to require `ALLOWED_DIRS` (exits if unset) |
167
+ | `MOONBRIDGE_SANDBOX` | Set to `1` to run agents in a temp copy of cwd |
168
+ | `MOONBRIDGE_SANDBOX_KEEP` | Set to `1` to keep sandbox dir for inspection |
169
+ | `MOONBRIDGE_SANDBOX_MAX_DIFF` | Max diff size in bytes (default 500000) |
170
+ | `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
166
171
  | `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
167
172
 
168
173
  ## Troubleshooting
@@ -229,6 +234,29 @@ By default, Moonbridge warns at startup if no directory restrictions are configu
229
234
  export MOONBRIDGE_ALLOWED_DIRS="/path/to/project:/another/path"
230
235
  ```
231
236
 
237
+ ## Sandbox Mode (Copy-on-Run)
238
+
239
+ Enable sandbox mode to run agents in a temporary copy of the working directory:
240
+
241
+ ```bash
242
+ export MOONBRIDGE_SANDBOX=1
243
+ ```
244
+
245
+ When enabled:
246
+ - Agents run in a temp copy of `cwd`.
247
+ - Host files stay unchanged by default.
248
+ - A unified diff + summary is included in `raw.sandbox`.
249
+
250
+ Optional:
251
+
252
+ ```bash
253
+ export MOONBRIDGE_SANDBOX_KEEP=1 # keep temp dir
254
+ export MOONBRIDGE_SANDBOX_MAX_DIFF=200000
255
+ export MOONBRIDGE_SANDBOX_MAX_COPY=300000000
256
+ ```
257
+
258
+ Limitations: this is not OS-level isolation. Agents can still read/write arbitrary host paths if they choose to. Use containers/VMs for strong isolation.
259
+
232
260
  To enforce restrictions (exit instead of warn):
233
261
 
234
262
  ```bash
@@ -0,0 +1,15 @@
1
+ moonbridge/__init__.py,sha256=8Yz-_YwybWMK-Sze7iG20GXQGLCNhL7dBjHmblAHULA,198
2
+ moonbridge/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
+ moonbridge/sandbox.py,sha256=4-eIu-rURtaxKKg-d3iWwNq_x6MB_uiJBBG2uMwoKcM,7907
4
+ moonbridge/server.py,sha256=FqnoAd-WAWOX-elNgyOQ-vAiFqsGOdsyQCIsnu1e1t0,19469
5
+ moonbridge/tools.py,sha256=uw338Dilrto2t5dL9XbK4O31-JdB7Vh9RqCXHg20gHI,10126
6
+ moonbridge/version_check.py,sha256=VQueK0O_b-2Xc-XjupJsoW3Zs1Kce5q_BgqBhANGXN8,4579
7
+ moonbridge/adapters/__init__.py,sha256=w3pLvjtC2XnUhf9UzNmniQB3oq4rG8gorSH0tWR-BEE,988
8
+ moonbridge/adapters/base.py,sha256=REoEsAcqEvyVQpTgz6ytd9ioxag--nnvX90YBXMQG8Y,1716
9
+ moonbridge/adapters/codex.py,sha256=G0q_A6vP5Usqix3sE8ssrG_qzrCWMeFcO9oWcNKTO3g,2964
10
+ moonbridge/adapters/kimi.py,sha256=ejCxG2OGr0Qr4n0psL6p96_mMJ3lLKMbGcNYWkuC0uA,2189
11
+ moonbridge-0.8.0.dist-info/METADATA,sha256=RiRWB1Hr5IFT3wEpRHVcqIclyBS2CORWE-j90Z4C19U,8305
12
+ moonbridge-0.8.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
13
+ moonbridge-0.8.0.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
14
+ moonbridge-0.8.0.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
15
+ moonbridge-0.8.0.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- moonbridge/__init__.py,sha256=K5-NRJiYdFbIITQmjIg3_Fkef0UEWhe2hqO3rJ0MZII,198
2
- moonbridge/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
- moonbridge/server.py,sha256=o-u0J-HsVoa7sxaweWPHo_iHDXJfao16eDFe_9bm6RA,17071
4
- moonbridge/tools.py,sha256=uw338Dilrto2t5dL9XbK4O31-JdB7Vh9RqCXHg20gHI,10126
5
- moonbridge/version_check.py,sha256=VQueK0O_b-2Xc-XjupJsoW3Zs1Kce5q_BgqBhANGXN8,4579
6
- moonbridge/adapters/__init__.py,sha256=w3pLvjtC2XnUhf9UzNmniQB3oq4rG8gorSH0tWR-BEE,988
7
- moonbridge/adapters/base.py,sha256=REoEsAcqEvyVQpTgz6ytd9ioxag--nnvX90YBXMQG8Y,1716
8
- moonbridge/adapters/codex.py,sha256=GtU4CrJ4zt0WDcKKaOeN7gH4JFIBAo3L7KAZ99zRjiY,2935
9
- moonbridge/adapters/kimi.py,sha256=ejCxG2OGr0Qr4n0psL6p96_mMJ3lLKMbGcNYWkuC0uA,2189
10
- moonbridge-0.6.0.dist-info/METADATA,sha256=nJndtrv2GuSSPeNrV6ryJa6-82viUtankQXduB6Nkn4,7298
11
- moonbridge-0.6.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- moonbridge-0.6.0.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
13
- moonbridge-0.6.0.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
14
- moonbridge-0.6.0.dist-info/RECORD,,