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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- 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
|