moonbridge 0.7.0__py3-none-any.whl → 0.9.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.7.0"
5
+ __version__ = "0.9.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/server.py CHANGED
@@ -21,6 +21,7 @@ from mcp.types import TextContent, Tool
21
21
 
22
22
  from moonbridge.adapters import ADAPTER_REGISTRY, CLIAdapter, get_adapter
23
23
  from moonbridge.adapters.base import AgentResult
24
+ from moonbridge.signals import extract_quality_signals
24
25
  from moonbridge.tools import build_tools
25
26
 
26
27
  server = Server("moonbridge")
@@ -78,6 +79,22 @@ def _warn_if_unrestricted() -> None:
78
79
  print(message, file=sys.stderr)
79
80
 
80
81
 
82
+ def _validate_allowed_dirs() -> None:
83
+ if not ALLOWED_DIRS:
84
+ return
85
+ missing_count = 0
86
+ for path in ALLOWED_DIRS:
87
+ if os.path.isdir(path):
88
+ continue
89
+ missing_count += 1
90
+ logger.warning("MOONBRIDGE_ALLOWED_DIRS entry does not exist: %s", path)
91
+ if missing_count == len(ALLOWED_DIRS) and STRICT_MODE:
92
+ message = "MOONBRIDGE_ALLOWED_DIRS entries do not exist"
93
+ logger.error(message)
94
+ print(message, file=sys.stderr)
95
+ sys.exit(1)
96
+
97
+
81
98
  def _safe_env(adapter: CLIAdapter) -> dict[str, str]:
82
99
  env = {key: os.environ[key] for key in adapter.config.safe_env_keys if key in os.environ}
83
100
  if "PATH" not in env and "PATH" in os.environ:
@@ -327,7 +344,7 @@ def _run_cli_sync(
327
344
  )
328
345
  status = "success" if proc.returncode == 0 else "error"
329
346
  logger.info("Agent %s completed with status: %s", agent_index, status)
330
- return AgentResult(
347
+ result = AgentResult(
331
348
  status=status,
332
349
  output=stdout,
333
350
  stderr=stderr_value,
@@ -335,6 +352,13 @@ def _run_cli_sync(
335
352
  duration_ms=duration_ms,
336
353
  agent_index=agent_index,
337
354
  )
355
+ if result.status == "success":
356
+ signals = extract_quality_signals(result.output, result.stderr)
357
+ if signals:
358
+ raw = dict(result.raw or {})
359
+ raw["quality_signals"] = signals
360
+ result = replace(result, raw=raw)
361
+ return result
338
362
  except TimeoutExpired:
339
363
  _terminate_process(proc)
340
364
  duration_ms = int((time.monotonic() - start) * 1000)
@@ -401,6 +425,12 @@ def _json_text(payload: Any) -> list[TextContent]:
401
425
 
402
426
 
403
427
  def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
428
+ config = {
429
+ "strict_mode": STRICT_MODE,
430
+ "allowed_dirs": ALLOWED_DIRS or None,
431
+ "unrestricted": not ALLOWED_DIRS,
432
+ "cwd": cwd,
433
+ }
404
434
  installed, _path = adapter.check_installed()
405
435
  if not installed:
406
436
  return {
@@ -408,20 +438,27 @@ def _status_check(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
408
438
  "message": (
409
439
  f"{adapter.config.name} CLI not found. Install: {adapter.config.install_hint}"
410
440
  ),
441
+ "config": config,
411
442
  }
412
443
  timeout = min(DEFAULT_TIMEOUT, 60)
413
444
  result = _run_cli_sync(adapter, "status check", False, cwd, timeout, 0)
414
445
  if result.status == "auth_error":
415
- return {"status": "auth_error", "message": adapter.config.auth_message}
446
+ return {
447
+ "status": "auth_error",
448
+ "message": adapter.config.auth_message,
449
+ "config": config,
450
+ }
416
451
  if result.status == "success":
417
452
  return {
418
453
  "status": "success",
419
454
  "message": f"{adapter.config.name} CLI available and authenticated",
455
+ "config": config,
420
456
  }
421
457
  return {
422
458
  "status": "error",
423
459
  "message": f"{adapter.config.name} CLI error",
424
460
  "details": result.to_dict(),
461
+ "config": config,
425
462
  }
426
463
 
427
464
 
@@ -444,6 +481,7 @@ def _adapter_info(cwd: str, adapter: CLIAdapter) -> dict[str, Any]:
444
481
 
445
482
  @server.list_tools()
446
483
  async def list_tools() -> list[Tool]:
484
+ """Build MCP tool metadata for the active adapter."""
447
485
  adapter = get_adapter()
448
486
  tool_desc = adapter.config.tool_description
449
487
  status_desc = f"Verify {adapter.config.name} CLI is installed and authenticated"
@@ -456,7 +494,15 @@ async def list_tools() -> list[Tool]:
456
494
 
457
495
 
458
496
  async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
459
- """Handle tool calls. Exposed for testing."""
497
+ """Dispatch a tool invocation with validation and stable error payloads.
498
+
499
+ Separated from ``call_tool`` so tests can invoke tool logic without the
500
+ MCP decorator.
501
+
502
+ Args:
503
+ name: MCP tool name (``spawn_agent``, ``spawn_agents_parallel``, etc.).
504
+ arguments: Tool argument payload from the MCP client.
505
+ """
460
506
  try:
461
507
  cwd = _validate_cwd(None)
462
508
  if name == "spawn_agent":
@@ -556,11 +602,12 @@ async def handle_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]
556
602
 
557
603
  @server.call_tool()
558
604
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
559
- """MCP tool handler - delegates to handle_tool."""
605
+ """MCP tool handler -- delegates to ``handle_tool`` for testability."""
560
606
  return await handle_tool(name, arguments)
561
607
 
562
608
 
563
609
  async def run() -> None:
610
+ """Run the MCP server over stdio until the client disconnects."""
564
611
  async with stdio_server() as (read_stream, write_stream):
565
612
  await server.run(
566
613
  read_stream,
@@ -570,8 +617,10 @@ async def run() -> None:
570
617
 
571
618
 
572
619
  def main() -> None:
620
+ """CLI entry point that validates prerequisites then starts the server."""
573
621
  _configure_logging()
574
622
  _warn_if_unrestricted()
623
+ _validate_allowed_dirs()
575
624
  from moonbridge import __version__
576
625
  from moonbridge.version_check import check_for_updates
577
626
 
moonbridge/signals.py ADDED
@@ -0,0 +1,68 @@
1
+ """Heuristic extraction of quality signals from agent output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ # Diff markers at line start.
9
+ _DIFF_MARKER_RE = re.compile(r"^(?:\+\+\+ |--- |@@ )", re.MULTILINE)
10
+ # File headers in unified diffs.
11
+ _DIFF_FILE_RE = re.compile(r"^(?:\+\+\+ b/|--- a/)(.+)$", re.MULTILINE)
12
+ # Git-style summary lines.
13
+ _FILES_CHANGED_RE = re.compile(r"\b(\d+)\s+files?\s+changed\b", re.IGNORECASE)
14
+ _MODIFIED_FILES_RE = re.compile(r"\bModified\s+(\d+)\s+files?\b", re.IGNORECASE)
15
+ # Pytest-style summaries.
16
+ _PASSED_RE = re.compile(r"(?<!\w)(\d+)\s+passed\b", re.IGNORECASE)
17
+ _FAILED_RE = re.compile(r"(?<!\w)(\d+)\s+failed\b", re.IGNORECASE)
18
+ # stderr error markers.
19
+ _ERROR_RE = re.compile(r"(Traceback \(most recent call last\)|\berror:)", re.IGNORECASE)
20
+
21
+
22
+ def _last_int(pattern: re.Pattern[str], text: str) -> int | None:
23
+ matches = pattern.findall(text)
24
+ if not matches:
25
+ return None
26
+ return int(matches[-1])
27
+
28
+
29
+ def _count_files_changed(output: str) -> int:
30
+ paths = {path for path in _DIFF_FILE_RE.findall(output) if path and path != "/dev/null"}
31
+ if paths:
32
+ return len(paths)
33
+ match = _FILES_CHANGED_RE.search(output) or _MODIFIED_FILES_RE.search(output)
34
+ if match:
35
+ return int(match.group(1))
36
+ return 0
37
+
38
+
39
+ def extract_quality_signals(output: str, stderr: str | None = None) -> dict[str, Any]:
40
+ """Extract heuristic quality signals from agent output."""
41
+ signals: dict[str, Any] = {}
42
+ if not output and not stderr:
43
+ return signals
44
+
45
+ has_diff = bool(_DIFF_MARKER_RE.search(output))
46
+ if has_diff:
47
+ signals["has_diff"] = True
48
+
49
+ files_changed = _count_files_changed(output)
50
+ if files_changed:
51
+ signals["files_changed"] = files_changed
52
+
53
+ combined = output
54
+ if stderr:
55
+ combined = f"{output}\n{stderr}"
56
+
57
+ tests_passed = _last_int(_PASSED_RE, combined)
58
+ if tests_passed is not None:
59
+ signals["tests_passed"] = tests_passed
60
+
61
+ tests_failed = _last_int(_FAILED_RE, combined)
62
+ if tests_failed is not None:
63
+ signals["tests_failed"] = tests_failed
64
+
65
+ if stderr and _ERROR_RE.search(stderr):
66
+ signals["has_errors"] = True
67
+
68
+ return signals
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moonbridge
3
- Version: 0.7.0
3
+ Version: 0.9.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
@@ -90,6 +90,32 @@ export MOONBRIDGE_SKIP_UPDATE_CHECK=1
90
90
 
91
91
  **Best for:** Tasks that benefit from parallel execution or volume.
92
92
 
93
+ ## How it Works
94
+
95
+ ### Connection Flow
96
+ 1. MCP client (Claude Code, Cursor, etc.) connects to Moonbridge over stdio
97
+ 2. Client discovers available tools via `list_tools`
98
+ 3. Client calls `spawn_agent` or `spawn_agents_parallel`
99
+
100
+ ### Spawn Process
101
+ 1. Moonbridge validates the prompt and working directory
102
+ 2. Resolves which adapter to use (Kimi, Codex)
103
+ 3. Adapter builds the CLI command with appropriate flags
104
+ 4. Spawns subprocess in a separate process group
105
+ 5. Captures stdout/stderr, enforces timeout
106
+ 6. Returns structured JSON result
107
+
108
+ ### Parallel Execution
109
+ - `spawn_agents_parallel` runs up to 10 agents concurrently via `asyncio.gather`
110
+ - Each agent is independent (separate process, separate output)
111
+ - All results returned together when the last agent finishes (or times out)
112
+
113
+ ```
114
+ MCP Client → stdio → Moonbridge → adapter → CLI subprocess
115
+ → CLI subprocess (parallel)
116
+ → CLI subprocess (parallel)
117
+ ```
118
+
93
119
  ## Tools
94
120
 
95
121
  | Tool | Use case |
@@ -170,6 +196,31 @@ All tools return JSON with these fields:
170
196
  | `MOONBRIDGE_SANDBOX_MAX_COPY` | Max sandbox copy size in bytes (default 500MB) |
171
197
  | `MOONBRIDGE_LOG_LEVEL` | Set to `DEBUG` for verbose logging |
172
198
 
199
+ ## Security
200
+
201
+ ### 1. Directory Restrictions (`MOONBRIDGE_ALLOWED_DIRS`)
202
+
203
+ Default: agents can operate in any directory. Set `MOONBRIDGE_ALLOWED_DIRS` to restrict: colon-separated allowed paths. Symlinks resolved via `os.path.realpath` before checking. Strict mode (`MOONBRIDGE_STRICT=1`) exits on startup if no valid allowed directories are configured.
204
+
205
+ ```bash
206
+ export MOONBRIDGE_ALLOWED_DIRS="/home/user/projects:/home/user/work"
207
+ export MOONBRIDGE_STRICT=1 # require restrictions
208
+ ```
209
+
210
+ ### 2. Environment Sanitization
211
+
212
+ Only whitelisted env vars are passed to spawned agents. Each adapter defines its own allowlist (`PATH`, `HOME`, plus adapter-specific like `OPENAI_API_KEY` for Codex). Your shell environment (secrets, tokens, SSH keys) is not inherited by default.
213
+
214
+ ### 3. Input Validation
215
+
216
+ Model parameters are validated to prevent flag injection (values starting with `-` are rejected). Prompts are capped at 100,000 characters and cannot be empty.
217
+
218
+ ### 4. Process Isolation
219
+
220
+ Agents run in separate process groups (`start_new_session=True`). Orphan cleanup on exit. Sandbox mode available (`MOONBRIDGE_SANDBOX=1`) for copy-on-run isolation.
221
+
222
+ > **Not OS-level sandboxing.** Agents can still read arbitrary host files. For strong isolation, use containers/VMs.
223
+
173
224
  ## Troubleshooting
174
225
 
175
226
  ### "CLI not found"
@@ -1,15 +1,16 @@
1
- moonbridge/__init__.py,sha256=vAOZaP2bQ71gulCrthXRbsd5zOWB5R3cdUHNrLuS87w,198
1
+ moonbridge/__init__.py,sha256=ARkdZ2AN2BQF7Jf4ECee-WXhmAGnSDwkJoftXCqure4,198
2
2
  moonbridge/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
3
3
  moonbridge/sandbox.py,sha256=4-eIu-rURtaxKKg-d3iWwNq_x6MB_uiJBBG2uMwoKcM,7907
4
- moonbridge/server.py,sha256=FqnoAd-WAWOX-elNgyOQ-vAiFqsGOdsyQCIsnu1e1t0,19469
4
+ moonbridge/server.py,sha256=bRvDSvxpmbRWeMKA1JAczmqL2PcpsWfZtjM1jGQL0OE,21195
5
+ moonbridge/signals.py,sha256=tpdKuVBIrQ9XOZm6WaxcNSSPj9WqWUYp6aGseGq9W6I,2210
5
6
  moonbridge/tools.py,sha256=uw338Dilrto2t5dL9XbK4O31-JdB7Vh9RqCXHg20gHI,10126
6
7
  moonbridge/version_check.py,sha256=VQueK0O_b-2Xc-XjupJsoW3Zs1Kce5q_BgqBhANGXN8,4579
7
8
  moonbridge/adapters/__init__.py,sha256=w3pLvjtC2XnUhf9UzNmniQB3oq4rG8gorSH0tWR-BEE,988
8
9
  moonbridge/adapters/base.py,sha256=REoEsAcqEvyVQpTgz6ytd9ioxag--nnvX90YBXMQG8Y,1716
9
- moonbridge/adapters/codex.py,sha256=GtU4CrJ4zt0WDcKKaOeN7gH4JFIBAo3L7KAZ99zRjiY,2935
10
+ moonbridge/adapters/codex.py,sha256=G0q_A6vP5Usqix3sE8ssrG_qzrCWMeFcO9oWcNKTO3g,2964
10
11
  moonbridge/adapters/kimi.py,sha256=ejCxG2OGr0Qr4n0psL6p96_mMJ3lLKMbGcNYWkuC0uA,2189
11
- moonbridge-0.7.0.dist-info/METADATA,sha256=cWa3osY8GxLAkoJPkkwT4sOKYWkcW80eMjdPcv2FPKw,8305
12
- moonbridge-0.7.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
13
- moonbridge-0.7.0.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
14
- moonbridge-0.7.0.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
15
- moonbridge-0.7.0.dist-info/RECORD,,
12
+ moonbridge-0.9.0.dist-info/METADATA,sha256=T_721mqFlkFCMcUvgimOVMT79fJ12bqpe1XDTxVmbIo,10564
13
+ moonbridge-0.9.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
14
+ moonbridge-0.9.0.dist-info/entry_points.txt,sha256=kgL38HQy3adncDQl_o5sdtPRog56zKdHk6pKKzyR6Ww,54
15
+ moonbridge-0.9.0.dist-info/licenses/LICENSE,sha256=7WMSJoybL2cUot_wb9GUrw5mzfFmtrDzqlMS9ZE709g,1065
16
+ moonbridge-0.9.0.dist-info/RECORD,,