monkeybot 2.1.1__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.
Files changed (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,284 @@
1
+ """Permission ruleset DSL: last-match-wins allow / ask / deny + session approvals.
2
+
3
+ Soft preflight layer on top of hard execution constraints (command allowlists +
4
+ sandbox). Patterns use ``fnmatch`` wildcards (``*``, ``?``) against tool name and
5
+ a normalized resource string.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fnmatch
11
+ import json
12
+ import logging
13
+ import shlex
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Literal, cast
17
+
18
+ import yaml
19
+
20
+ from monkeybot.core.context import TurnContext
21
+ from monkeybot.core.logging_utils import kv
22
+ from monkeybot.core.tools.inspector import (
23
+ Decision,
24
+ InspectorToolCall,
25
+ norm_run_command_line,
26
+ )
27
+ from monkeybot.core.types.interfaces import MonkeybotError
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ Effect = Literal["allow", "ask", "deny"]
32
+
33
+
34
+ class PermissionConfigError(MonkeybotError):
35
+ """Invalid or unloadable permissions YAML."""
36
+
37
+ def __init__(self, path: Path, reason: str) -> None:
38
+ self.path = path
39
+ self.reason = reason
40
+ super().__init__(f"{path}: {reason}")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class PermissionRule:
45
+ """One ruleset entry. Evaluation is last-match-wins across the ordered list."""
46
+
47
+ tool: str
48
+ pattern: str
49
+ effect: Effect
50
+ message: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class PermissionRuleset:
55
+ """Loaded permissions config."""
56
+
57
+ rules: tuple[PermissionRule, ...]
58
+ default: Effect = "allow"
59
+
60
+
61
+ def resource_for_call(call: InspectorToolCall) -> str:
62
+ """Normalize a tool call into a single resource string for pattern matching."""
63
+ if call.name == "run_command":
64
+ line = norm_run_command_line(call.args)
65
+ if line:
66
+ return line
67
+ path = call.args.get("path")
68
+ if isinstance(path, str) and path.strip():
69
+ return path.strip()
70
+ argv = call.args.get("argv")
71
+ if isinstance(argv, list) and argv:
72
+ return shlex.join(str(x) for x in argv)
73
+ command = call.args.get("command")
74
+ if isinstance(command, str) and command.strip():
75
+ return " ".join(command.strip().split())
76
+ # Stable fallback for tool calls with none of the recognized shapes above.
77
+ # Sort keys so the same logical args produce the same resource string
78
+ # regardless of dict insertion order (e.g. across provider round-trips).
79
+ return json.dumps(call.args, sort_keys=True, default=str)
80
+
81
+
82
+ def _match(rule: PermissionRule, tool: str, resource: str) -> bool:
83
+ return fnmatch.fnmatchcase(tool, rule.tool) and fnmatch.fnmatchcase(
84
+ resource, rule.pattern
85
+ )
86
+
87
+
88
+ def evaluate(tool: str, resource: str, rules: tuple[PermissionRule, ...]) -> PermissionRule | None:
89
+ """Return the last matching rule, or ``None`` if nothing matched."""
90
+ matched: PermissionRule | None = None
91
+ for rule in rules:
92
+ if _match(rule, tool, resource):
93
+ matched = rule
94
+ return matched
95
+
96
+
97
+ def load_permissions(path: Path) -> PermissionRuleset:
98
+ """Load ``permissions.yaml`` (``default`` + ordered ``rules``)."""
99
+ raw_bytes = path.read_bytes()
100
+ try:
101
+ data = yaml.safe_load(raw_bytes)
102
+ except yaml.YAMLError as e:
103
+ raise PermissionConfigError(path, f"invalid YAML: {e}") from e
104
+
105
+ if data is None:
106
+ return PermissionRuleset(rules=(), default="allow")
107
+ if not isinstance(data, dict):
108
+ raise PermissionConfigError(path, "root must be a mapping")
109
+
110
+ default_raw = data.get("default", "allow")
111
+ if default_raw not in ("allow", "ask", "deny"):
112
+ raise PermissionConfigError(
113
+ path, f"'default' must be allow|ask|deny, got {default_raw!r}"
114
+ )
115
+ default = cast(Effect, default_raw)
116
+
117
+ rules_raw = data.get("rules", [])
118
+ if rules_raw is None:
119
+ rules_raw = []
120
+ if not isinstance(rules_raw, list):
121
+ raise PermissionConfigError(path, "'rules' must be a list")
122
+
123
+ rules: list[PermissionRule] = []
124
+ for i, item in enumerate(rules_raw):
125
+ if not isinstance(item, dict):
126
+ raise PermissionConfigError(path, f"rules[{i}] must be a mapping")
127
+ tool = item.get("tool")
128
+ pattern = item.get("pattern", "*")
129
+ effect = item.get("effect")
130
+ message = item.get("message")
131
+ if not isinstance(tool, str) or not tool.strip():
132
+ raise PermissionConfigError(path, f"rules[{i}].tool must be a non-empty string")
133
+ if not isinstance(pattern, str) or not pattern.strip():
134
+ raise PermissionConfigError(
135
+ path, f"rules[{i}].pattern must be a non-empty string"
136
+ )
137
+ if effect not in ("allow", "ask", "deny"):
138
+ raise PermissionConfigError(
139
+ path, f"rules[{i}].effect must be allow|ask|deny, got {effect!r}"
140
+ )
141
+ if message is not None and not isinstance(message, str):
142
+ raise PermissionConfigError(path, f"rules[{i}].message must be a string")
143
+ rules.append(
144
+ PermissionRule(
145
+ tool=tool.strip(),
146
+ pattern=pattern.strip(),
147
+ effect=cast(Effect, effect),
148
+ message=message,
149
+ )
150
+ )
151
+
152
+ return PermissionRuleset(rules=tuple(rules), default=default)
153
+
154
+
155
+ @dataclass
156
+ class SessionApprovals:
157
+ """In-memory session-scoped 'always allow' rememberies (OpenCode ``reply: always``).
158
+
159
+ Process-local only (same durability class as ``SessionBus.admission``): not
160
+ shared across gateway replicas and lost on restart.
161
+ """
162
+
163
+ _keys: set[tuple[str, str]] = field(default_factory=set)
164
+
165
+ def is_allowed(self, tool: str, resource: str) -> bool:
166
+ return (tool, resource) in self._keys
167
+
168
+ def remember(self, tool: str, resource: str) -> None:
169
+ self._keys.add((tool, resource))
170
+
171
+
172
+ def remember_always_approval(bus: object | None, tool: str, resource: str) -> None:
173
+ """Record a session "always allow" approval on ``bus.session_approvals`` if present.
174
+
175
+ Shared by the text (``loop.py``) and realtime (``realtime_loop.py``) HITL confirm
176
+ paths so the ``payload.get("always")`` handling isn't duplicated across both.
177
+ No-op when ``bus`` is ``None`` or has no ``session_approvals`` attribute.
178
+ """
179
+ approvals = getattr(bus, "session_approvals", None)
180
+ if approvals is not None:
181
+ approvals.remember(tool, resource)
182
+
183
+
184
+ def _decision_from_effect(effect: Effect, message: str | None) -> Decision:
185
+ if effect == "allow":
186
+ return Decision(kind="allow")
187
+ if effect == "deny":
188
+ return Decision(kind="deny", message=message or "denied by permission ruleset")
189
+ return Decision(kind="confirm", message=message or "Approval required for this tool call")
190
+
191
+
192
+ class PermissionInspector:
193
+ """``ToolInspector`` that evaluates a last-match-wins permission ruleset.
194
+
195
+ Session approvals (when present on ``ctx.sse_bus.session_approvals``) short-circuit
196
+ to allow before ruleset evaluation.
197
+
198
+ ``allow_ask`` controls whether an ``ask`` effect may prompt interactively
199
+ (``confirm``, the default) or must resolve deterministically. Set
200
+ ``allow_ask=False`` for non-interactive callers (e.g. subagents) that have
201
+ no session to prompt: ``ask`` is then treated as ``deny`` rather than
202
+ surfacing a confirmation that can never be answered.
203
+ """
204
+
205
+ def __init__(self, ruleset: PermissionRuleset, *, allow_ask: bool = True) -> None:
206
+ self._ruleset = ruleset
207
+ self._allow_ask = allow_ask
208
+
209
+ @property
210
+ def ruleset(self) -> PermissionRuleset:
211
+ return self._ruleset
212
+
213
+ async def check(self, call: InspectorToolCall, ctx: TurnContext) -> Decision:
214
+ resource = resource_for_call(call)
215
+ bus = ctx.sse_bus
216
+ if bus is not None:
217
+ approvals = getattr(bus, "session_approvals", None)
218
+ if isinstance(approvals, SessionApprovals) and approvals.is_allowed(
219
+ call.name, resource
220
+ ):
221
+ logger.debug(
222
+ "permission session allow %s",
223
+ kv(tool=call.name, call_id=call.call_id, resource=resource),
224
+ )
225
+ return Decision(kind="allow")
226
+
227
+ matched = evaluate(call.name, resource, self._ruleset.rules)
228
+ effect = self._ruleset.default if matched is None else matched.effect
229
+ message = None if matched is None else matched.message
230
+ if effect == "ask" and not self._allow_ask:
231
+ logger.debug(
232
+ "permission ask->deny (no interactive session) %s",
233
+ kv(tool=call.name, call_id=call.call_id, resource=resource),
234
+ )
235
+ return Decision(
236
+ kind="deny",
237
+ message=message or "ask effect requires an interactive session; denied",
238
+ )
239
+ return _decision_from_effect(effect, message)
240
+
241
+
242
+ def try_load_permission_inspector(
243
+ path: Path, *, allow_ask: bool = True
244
+ ) -> PermissionInspector | None:
245
+ """Load ``permissions.yaml`` into an inspector, or ``None`` if missing/invalid.
246
+
247
+ Missing file → ``None`` (logged at info). Invalid YAML → ``None`` (logged at
248
+ exception). Soft ruleset is fail-open; hard allowlists/sandbox still apply.
249
+ ``allow_ask`` is forwarded to :class:`PermissionInspector` (set ``False`` for
250
+ non-interactive callers such as subagents; ``ask`` then resolves to ``deny``).
251
+ """
252
+ try:
253
+ ruleset = load_permissions(path)
254
+ except FileNotFoundError:
255
+ logger.info("permissions.yaml missing (%s); soft ruleset disabled", path)
256
+ return None
257
+ except PermissionConfigError:
258
+ logger.exception("permission ruleset load failed (%s)", path)
259
+ return None
260
+ except Exception:
261
+ logger.exception("permission ruleset load failed (%s)", path)
262
+ return None
263
+ logger.info(
264
+ "permission ruleset loaded (%s rules, default=%s, allow_ask=%s)",
265
+ len(ruleset.rules),
266
+ ruleset.default,
267
+ allow_ask,
268
+ )
269
+ return PermissionInspector(ruleset, allow_ask=allow_ask)
270
+
271
+
272
+ __all__ = [
273
+ "Effect",
274
+ "PermissionConfigError",
275
+ "PermissionInspector",
276
+ "PermissionRule",
277
+ "PermissionRuleset",
278
+ "SessionApprovals",
279
+ "evaluate",
280
+ "load_permissions",
281
+ "remember_always_approval",
282
+ "resource_for_call",
283
+ "try_load_permission_inspector",
284
+ ]
@@ -0,0 +1,255 @@
1
+ """Sandbox-backed command executor using OpenSandbox.
2
+
3
+ Drop-in replacement for TerminalExecutor when SANDBOX_ENABLED=true.
4
+ Routes run_command through an isolated Docker container managed by the
5
+ OpenSandbox server, while preserving the binary allowlist from TerminalExecutor.
6
+
7
+ The workspace directory is mounted read-write into the sandbox container at
8
+ the same absolute path, so file writes from run_command persist to the host
9
+ filesystem immediately and all relative agent paths resolve identically.
10
+
11
+ One sandbox is created per SandboxExecutor instance (session-scoped) and
12
+ reused across all run_command calls in that session. The sandbox is created
13
+ lazily on the first execute() call so there is zero overhead when run_command
14
+ is never invoked.
15
+
16
+ Configuration (all via env vars or monkeybot.yaml sandbox.*):
17
+ SANDBOX_ENABLED - "true" to activate (default: "false")
18
+ SANDBOX_SERVER_URL - OpenSandbox server URL (default: http://localhost:8080)
19
+ SANDBOX_IMAGE - Container image to run (default: python:3.12)
20
+ SANDBOX_API_KEY - API key for the server (env-only, optional)
21
+ SANDBOX_TTL_SECONDS - Sandbox lifetime in seconds (env-only, default: 1800)
22
+ SANDBOX_USE_SERVER_PROXY - default "true": route health/exec via OpenSandbox HTTP API
23
+ (required for Docker Desktop when the server runs in a container). "false" if the SDK
24
+ host can reach sandbox ports directly.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import os
31
+ import re
32
+ import shlex
33
+ from collections.abc import Sequence
34
+ from dataclasses import dataclass
35
+ from datetime import timedelta
36
+ from pathlib import Path
37
+ from typing import Any
38
+
39
+ from monkeybot.core.tools.terminal import ALLOWED_PATHS, ALLOWED_COMMANDS, ExecutionResult, SecurityError, build_skill_runtime_env
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ @dataclass
45
+ class SandboxConfig:
46
+ """Configuration for the OpenSandbox integration.
47
+
48
+ Populated from environment variables set by monkeybot.yaml or directly.
49
+ Only SANDBOX_ENABLED=true (exact, case-insensitive) activates the sandbox.
50
+ """
51
+
52
+ enabled: bool
53
+ server_url: str
54
+ api_key: str | None
55
+ image: str
56
+ ttl_seconds: int
57
+ use_server_proxy: bool = True
58
+
59
+ @classmethod
60
+ def from_env(cls) -> "SandboxConfig":
61
+ raw_ttl = os.getenv("SANDBOX_TTL_SECONDS", "1800")
62
+ try:
63
+ ttl = int(raw_ttl)
64
+ except ValueError:
65
+ raise ValueError(
66
+ f"SANDBOX_TTL_SECONDS must be an integer, got: {raw_ttl!r}"
67
+ )
68
+ api_key = os.getenv("SANDBOX_API_KEY") or None
69
+ proxy_raw = os.getenv("SANDBOX_USE_SERVER_PROXY", "true").strip().lower()
70
+ use_server_proxy = proxy_raw not in ("0", "false", "no", "off")
71
+ return cls(
72
+ enabled=os.getenv("SANDBOX_ENABLED", "false").lower() == "true",
73
+ server_url=os.getenv("SANDBOX_SERVER_URL", "http://localhost:8080"),
74
+ api_key=api_key,
75
+ image=os.getenv("SANDBOX_IMAGE", "python:3.12"),
76
+ ttl_seconds=ttl,
77
+ use_server_proxy=use_server_proxy,
78
+ )
79
+
80
+
81
+ class SandboxExecutor:
82
+ """Command executor that routes shell commands through an OpenSandbox container.
83
+
84
+ Same interface as TerminalExecutor.execute(). Binary allowlist from
85
+ terminal.py is applied before dispatch (defense-in-depth — the container
86
+ provides OS-level isolation, the allowlist enforces the command policy).
87
+
88
+ The sandbox is created lazily on the first execute() call and reused for
89
+ the lifetime of this instance (session-scoped). The workspace directory is
90
+ mounted read-write at its absolute path inside the container so file writes
91
+ persist to the host filesystem immediately.
92
+
93
+ Call aclose() to destroy the sandbox early; otherwise it expires after
94
+ ttl_seconds automatically.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ config: SandboxConfig,
100
+ workspace_root: Path,
101
+ *,
102
+ allowed_commands: Sequence[str] | None = None,
103
+ ) -> None:
104
+ self._config = config
105
+ self._workspace_root = Path(workspace_root).resolve()
106
+ self._sandbox: Any = None
107
+ self._allowed_commands: tuple[str, ...] = (
108
+ tuple(allowed_commands) if allowed_commands is not None else tuple(ALLOWED_COMMANDS)
109
+ )
110
+
111
+ @property
112
+ def allowed_commands(self) -> tuple[str, ...]:
113
+ return self._allowed_commands
114
+
115
+ @property
116
+ def allowed_path_prefixes(self) -> tuple[str, ...]:
117
+ """Path prefixes allowed for ``run_command`` error hints (host uses :class:`TerminalExecutor`)."""
118
+ return tuple(ALLOWED_PATHS)
119
+
120
+ async def _ensure_sandbox(self) -> None:
121
+ if self._sandbox is not None:
122
+ return
123
+
124
+ try:
125
+ from opensandbox import Sandbox
126
+ from opensandbox.config import ConnectionConfig
127
+ from opensandbox.models.sandboxes import Host, Volume
128
+ except ImportError as exc:
129
+ raise RuntimeError(
130
+ "opensandbox SDK is required for sandbox execution but is not installed. "
131
+ "Install it with: pip install 'monkeybot[sandbox]'"
132
+ ) from exc
133
+
134
+ workspace_str = str(self._workspace_root)
135
+ logger.info(
136
+ "Creating sandbox: image=%s ttl=%ss workspace=%s",
137
+ self._config.image,
138
+ self._config.ttl_seconds,
139
+ workspace_str,
140
+ )
141
+
142
+ # Parse server_url into domain + protocol for ConnectionConfig.
143
+ # server_url format: "http://host:port" or "https://host:port"
144
+ match = re.fullmatch(r"(https?)://(.+)", self._config.server_url.rstrip("/"))
145
+ if not match:
146
+ raise ValueError(
147
+ f"SANDBOX_SERVER_URL must be http(s)://host[:port], got: {self._config.server_url!r}"
148
+ )
149
+ protocol, domain = match.group(1), match.group(2)
150
+
151
+ connection_config = ConnectionConfig(
152
+ domain=domain,
153
+ api_key=self._config.api_key,
154
+ protocol=protocol,
155
+ use_server_proxy=self._config.use_server_proxy,
156
+ )
157
+
158
+ # Derive a DNS-safe volume name from the workspace path (max 63 chars).
159
+ vol_name = re.sub(r"[^a-z0-9-]", "-", workspace_str.lower()).strip("-")[:63]
160
+
161
+ runtime_env = build_skill_runtime_env(cwd=self._workspace_root)
162
+ volumes = [
163
+ Volume(
164
+ name=vol_name or "workspace",
165
+ host=Host(path=workspace_str),
166
+ mountPath=workspace_str,
167
+ readOnly=False,
168
+ )
169
+ ]
170
+ mounted_paths = {workspace_str}
171
+ for cred_env in ("GOOGLE_APPLICATION_CREDENTIALS", "GCP_AUTH_FILE"):
172
+ cred_path = runtime_env.get(cred_env, "").strip()
173
+ if not cred_path or cred_path in mounted_paths:
174
+ continue
175
+ if not Path(cred_path).is_file():
176
+ continue
177
+ cred_vol_name = re.sub(r"[^a-z0-9-]", "-", cred_path.lower()).strip("-")[:63]
178
+ volumes.append(
179
+ Volume(
180
+ name=cred_vol_name or "gcp-creds",
181
+ host=Host(path=cred_path),
182
+ mountPath=cred_path,
183
+ readOnly=True,
184
+ )
185
+ )
186
+ mounted_paths.add(cred_path)
187
+
188
+ self._sandbox = await Sandbox.create(
189
+ self._config.image,
190
+ connection_config=connection_config,
191
+ timeout=timedelta(seconds=self._config.ttl_seconds),
192
+ env=runtime_env,
193
+ volumes=volumes,
194
+ )
195
+ logger.info("Sandbox created: id=%s", getattr(self._sandbox, "id", "unknown"))
196
+
197
+ async def execute(
198
+ self,
199
+ command: str,
200
+ args: list[str],
201
+ *,
202
+ timeout: int = 60,
203
+ cwd: Path | str | None = None,
204
+ ) -> ExecutionResult:
205
+ """Execute a command inside the sandbox container.
206
+
207
+ Binary allowlist from ALLOWED_COMMANDS is enforced before the sandbox
208
+ is created or contacted. A blocked command raises SecurityError without
209
+ ever touching the OpenSandbox server.
210
+ """
211
+ if command not in self._allowed_commands:
212
+ raise SecurityError(f"Command '{command}' not allowed")
213
+
214
+ await self._ensure_sandbox()
215
+
216
+ full_cmd = " ".join([command] + [shlex.quote(a) for a in args])
217
+ logger.info("Sandbox execute: %s", full_cmd)
218
+
219
+ from opensandbox.models.execd import RunCommandOpts
220
+
221
+ workdir = str(Path(cwd).resolve()) if cwd is not None else str(self._workspace_root)
222
+ execution = await self._sandbox.commands.run(
223
+ full_cmd,
224
+ opts=RunCommandOpts(
225
+ timeout=timedelta(seconds=timeout),
226
+ working_directory=workdir,
227
+ ),
228
+ )
229
+
230
+ stdout_entries = execution.logs.stdout or []
231
+ stderr_entries = execution.logs.stderr or []
232
+ stdout = "".join(getattr(e, "text", str(e)) for e in stdout_entries)
233
+ stderr = "".join(getattr(e, "text", str(e)) for e in stderr_entries)
234
+
235
+ return ExecutionResult(
236
+ stdout=stdout,
237
+ stderr=stderr,
238
+ exit_code=execution.exit_code if execution.exit_code is not None else 0,
239
+ )
240
+
241
+ async def aclose(self) -> None:
242
+ """Destroy the sandbox container.
243
+
244
+ Safe to call multiple times (idempotent). Exceptions from kill() are
245
+ swallowed so the gateway finally block always completes cleanly.
246
+ """
247
+ if self._sandbox is None:
248
+ return
249
+ sandbox = self._sandbox
250
+ self._sandbox = None
251
+ try:
252
+ await sandbox.kill()
253
+ logger.info("Sandbox destroyed")
254
+ except Exception:
255
+ logger.warning("Failed to destroy sandbox", exc_info=True)
@@ -0,0 +1,35 @@
1
+ """Spill-file inventory notes for large tool outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot.core.runtime.context_budget import diff_inventory_lines
6
+
7
+ _INVENTORY_PREFIX = "[Spill inventory —"
8
+
9
+
10
+ def spill_inventory_note(text: str, rel_spill_path: str) -> str:
11
+ """Build an inventory note appended to large tool results."""
12
+ total_chars = len(text)
13
+ total_lines = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
14
+ parts = [
15
+ f"{_INVENTORY_PREFIX} {total_chars} total chars, {total_lines} total lines.",
16
+ ]
17
+ paths = diff_inventory_lines(text)
18
+ if paths:
19
+ parts.append(f"Changed files ({len(paths)}): " + ", ".join(paths[:80]))
20
+ if len(paths) > 80:
21
+ parts.append(f"... and {len(paths) - 80} more paths")
22
+ parts.append(
23
+ f"Full output at: {rel_spill_path} — use read_file with offset/limit to page through it.]"
24
+ )
25
+ return "\n".join(parts)
26
+
27
+
28
+ def spill_min_chars_from_env() -> int:
29
+ import os
30
+
31
+ raw = os.environ.get("MONKEYBOT_SPILL_MIN_CHARS", "8000").strip()
32
+ try:
33
+ return max(0, int(raw))
34
+ except ValueError:
35
+ return 8000