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,381 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secure terminal command execution with allowlist-based security.
|
|
3
|
+
|
|
4
|
+
This module provides the TerminalExecutor class, which is the security boundary
|
|
5
|
+
for all shell command execution in Monkeybot. It implements strict allowlist validation
|
|
6
|
+
for both commands and file paths to prevent unauthorized operations.
|
|
7
|
+
|
|
8
|
+
Security Model:
|
|
9
|
+
- Deny by default: Only pre-approved commands and paths are allowed
|
|
10
|
+
- Command allowlist: ALLOWED_COMMANDS defines executable binaries
|
|
11
|
+
- Path allowlist: ALLOWED_PATHS defines accessible directories
|
|
12
|
+
- Timeout enforcement: All commands have maximum execution time
|
|
13
|
+
- Output limits: Large outputs are truncated to prevent memory exhaustion
|
|
14
|
+
|
|
15
|
+
Example:
|
|
16
|
+
>>> executor = TerminalExecutor()
|
|
17
|
+
>>> result = await executor.execute("ls", ["./data/memory/"])
|
|
18
|
+
>>> print(result.stdout)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import List
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# SECURITY: Command allowlist - modify with extreme caution
|
|
33
|
+
# Only add commands that are essential and have been security reviewed
|
|
34
|
+
ALLOWED_COMMANDS = [
|
|
35
|
+
"cat", # Read file contents
|
|
36
|
+
"ls", # List directory contents
|
|
37
|
+
"grep", # Search file contents (line-oriented)
|
|
38
|
+
"echo", # Print text (used in tests and debugging)
|
|
39
|
+
"python", # Execute Python scripts (skills)
|
|
40
|
+
"python3", # Execute Python scripts (skills)
|
|
41
|
+
"uv", # Package runner (install subcommands blocked by deny_patterns)
|
|
42
|
+
"git", # Version control (clone, branch, commit, push, etc.)
|
|
43
|
+
"gh", # GitHub CLI (e.g. gh pr create)
|
|
44
|
+
"bash", # Shell interpreter — use for builtins (cd, &&, pipes) via bash -c "..."
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# SECURITY: Path allowlist - modify with extreme caution
|
|
48
|
+
# Only add paths that are safe for agent access
|
|
49
|
+
ALLOWED_PATHS = [
|
|
50
|
+
"./data/memory/", # Memory storage directory
|
|
51
|
+
"./data/memory", # Same, when callers omit trailing slash
|
|
52
|
+
"./skills/", # Skills directory
|
|
53
|
+
"./skills", # Same, when callers omit trailing slash
|
|
54
|
+
"./test-data/", # Test data directory (for tests only)
|
|
55
|
+
"./code/", # Reference / cloned repos (explicit ./ paths in argv)
|
|
56
|
+
"./code", # Same, when callers omit trailing slash
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_VERTEX_SKILL_ENV_KEYS = (
|
|
61
|
+
"VERTEX_AI_PROJECT_ID",
|
|
62
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
63
|
+
"GCP_PROJECT_ID",
|
|
64
|
+
"VERTEX_AI_LOCATION",
|
|
65
|
+
"GOOGLE_CLOUD_REGION",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_skill_runtime_env(*, cwd: Path | str) -> dict[str, str]:
|
|
70
|
+
"""Environment for skill scripts (inherits host env + workspace/GCP overrides)."""
|
|
71
|
+
exec_cwd = str(Path(cwd).resolve())
|
|
72
|
+
env = os.environ.copy()
|
|
73
|
+
env["MONKEYBOT_WORKSPACE_ROOT"] = exec_cwd
|
|
74
|
+
env["WORKSPACE_ROOT"] = exec_cwd
|
|
75
|
+
for key in _VERTEX_SKILL_ENV_KEYS:
|
|
76
|
+
val = os.environ.get(key, "").strip()
|
|
77
|
+
if val:
|
|
78
|
+
env[key] = val
|
|
79
|
+
for cred_env in ("GOOGLE_APPLICATION_CREDENTIALS", "GCP_AUTH_FILE"):
|
|
80
|
+
raw = os.environ.get(cred_env, "").strip()
|
|
81
|
+
if not raw:
|
|
82
|
+
continue
|
|
83
|
+
cred_path = Path(raw).expanduser()
|
|
84
|
+
if not cred_path.is_absolute():
|
|
85
|
+
cred_path = (Path.cwd() / cred_path).resolve()
|
|
86
|
+
else:
|
|
87
|
+
cred_path = cred_path.resolve()
|
|
88
|
+
env[cred_env] = str(cred_path)
|
|
89
|
+
return env
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ExecutionResult:
|
|
94
|
+
"""
|
|
95
|
+
Result from terminal command execution.
|
|
96
|
+
|
|
97
|
+
Attributes:
|
|
98
|
+
stdout: Standard output from command (decoded UTF-8)
|
|
99
|
+
stderr: Standard error from command (decoded UTF-8)
|
|
100
|
+
exit_code: Command exit code (0 = success, non-zero = error)
|
|
101
|
+
"""
|
|
102
|
+
stdout: str
|
|
103
|
+
stderr: str
|
|
104
|
+
exit_code: int
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class SecurityError(Exception):
|
|
108
|
+
"""
|
|
109
|
+
Raised when a security violation is detected.
|
|
110
|
+
|
|
111
|
+
This exception is raised when attempting to execute:
|
|
112
|
+
- A command not in ALLOWED_COMMANDS
|
|
113
|
+
- A command accessing paths not in ALLOWED_PATHS
|
|
114
|
+
|
|
115
|
+
Security violations are logged with ERROR severity for audit purposes.
|
|
116
|
+
"""
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TerminalExecutor:
|
|
121
|
+
"""
|
|
122
|
+
Secure terminal command executor with allowlist-based security.
|
|
123
|
+
|
|
124
|
+
This class is the security boundary for all shell command execution in Monkeybot.
|
|
125
|
+
It enforces strict allowlist validation for commands and file paths, implements
|
|
126
|
+
timeout handling, and limits output size to prevent resource exhaustion.
|
|
127
|
+
|
|
128
|
+
Security Features:
|
|
129
|
+
- Command allowlist validation (ALLOWED_COMMANDS)
|
|
130
|
+
- Path allowlist validation (ALLOWED_PATHS)
|
|
131
|
+
- Timeout enforcement with process cleanup
|
|
132
|
+
- Output size limits (1MB per stream)
|
|
133
|
+
- Security violation logging
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
>>> executor = TerminalExecutor()
|
|
137
|
+
>>>
|
|
138
|
+
>>> # Allowed command + allowed path - succeeds
|
|
139
|
+
>>> result = await executor.execute("cat", ["./data/memory/file.txt"])
|
|
140
|
+
>>> print(result.stdout)
|
|
141
|
+
>>>
|
|
142
|
+
>>> # Blocked command - raises SecurityError
|
|
143
|
+
>>> try:
|
|
144
|
+
>>> await executor.execute("rm", ["-rf", "/"])
|
|
145
|
+
>>> except SecurityError as e:
|
|
146
|
+
>>> print(f"Blocked: {e}")
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
allowed_commands: Sequence[str] | None = None,
|
|
153
|
+
allowed_path_prefixes: Sequence[str] | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
self._allowed_commands: tuple[str, ...] = (
|
|
156
|
+
tuple(allowed_commands) if allowed_commands is not None else tuple(ALLOWED_COMMANDS)
|
|
157
|
+
)
|
|
158
|
+
self._allowed_path_prefixes: tuple[str, ...] = (
|
|
159
|
+
tuple(allowed_path_prefixes)
|
|
160
|
+
if allowed_path_prefixes is not None
|
|
161
|
+
else tuple(ALLOWED_PATHS)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def allowed_commands(self) -> tuple[str, ...]:
|
|
166
|
+
return self._allowed_commands
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def allowed_path_prefixes(self) -> tuple[str, ...]:
|
|
170
|
+
return self._allowed_path_prefixes
|
|
171
|
+
|
|
172
|
+
async def aclose(self) -> None:
|
|
173
|
+
"""No-op — TerminalExecutor holds no persistent resources."""
|
|
174
|
+
|
|
175
|
+
async def execute(
|
|
176
|
+
self,
|
|
177
|
+
command: str,
|
|
178
|
+
args: List[str],
|
|
179
|
+
timeout: int = 60,
|
|
180
|
+
*,
|
|
181
|
+
cwd: Path | str | None = None,
|
|
182
|
+
) -> ExecutionResult:
|
|
183
|
+
"""
|
|
184
|
+
Execute a terminal command securely with allowlist validation.
|
|
185
|
+
|
|
186
|
+
This method is the single entry point for all shell command execution.
|
|
187
|
+
It performs security validation before execution and enforces resource
|
|
188
|
+
limits during execution.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
command: Command to execute (must be in ALLOWED_COMMANDS)
|
|
192
|
+
args: Command arguments (paths must be in ALLOWED_PATHS)
|
|
193
|
+
timeout: Maximum execution time in seconds (default: 60)
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
ExecutionResult containing stdout, stderr, and exit code
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
SecurityError: If command or path violates security policy
|
|
200
|
+
TimeoutError: If command exceeds timeout duration
|
|
201
|
+
|
|
202
|
+
Example:
|
|
203
|
+
>>> executor = TerminalExecutor()
|
|
204
|
+
>>> result = await executor.execute("ls", ["-la", "./data/memory/"])
|
|
205
|
+
>>> if result.exit_code == 0:
|
|
206
|
+
>>> print(f"Files: {result.stdout}")
|
|
207
|
+
|
|
208
|
+
Security Notes:
|
|
209
|
+
- This method logs all security violations with ERROR severity
|
|
210
|
+
- Failed security checks never execute the command
|
|
211
|
+
- Processes are killed if they exceed timeout
|
|
212
|
+
- Output is truncated if it exceeds 1MB per stream
|
|
213
|
+
"""
|
|
214
|
+
# CRITICAL: Validate command against allowlist
|
|
215
|
+
self._validate_command(command)
|
|
216
|
+
|
|
217
|
+
# CRITICAL: Validate all paths in arguments
|
|
218
|
+
self._validate_paths(args)
|
|
219
|
+
|
|
220
|
+
# Log execution for audit trail
|
|
221
|
+
logger.info(
|
|
222
|
+
f"Executing command: {command} {' '.join(args)}",
|
|
223
|
+
extra={
|
|
224
|
+
"component": "terminal_executor",
|
|
225
|
+
"command": command,
|
|
226
|
+
"args_count": len(args)
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
exec_cwd: str | None = None
|
|
231
|
+
env: dict[str, str] | None = None
|
|
232
|
+
if cwd is not None:
|
|
233
|
+
exec_cwd = str(Path(cwd).resolve())
|
|
234
|
+
env = build_skill_runtime_env(cwd=exec_cwd)
|
|
235
|
+
|
|
236
|
+
executable = sys.executable if command in ("python3", "python") else command
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
# Create subprocess with captured output
|
|
240
|
+
process = await asyncio.create_subprocess_exec(
|
|
241
|
+
executable,
|
|
242
|
+
*args,
|
|
243
|
+
stdout=asyncio.subprocess.PIPE,
|
|
244
|
+
stderr=asyncio.subprocess.PIPE,
|
|
245
|
+
cwd=exec_cwd,
|
|
246
|
+
env=env,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Wait for completion with timeout
|
|
250
|
+
stdout, stderr = await asyncio.wait_for(
|
|
251
|
+
process.communicate(),
|
|
252
|
+
timeout=timeout
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Truncate large outputs to prevent memory exhaustion
|
|
256
|
+
stdout = self._truncate_output(stdout, "stdout")
|
|
257
|
+
stderr = self._truncate_output(stderr, "stderr")
|
|
258
|
+
|
|
259
|
+
return ExecutionResult(
|
|
260
|
+
stdout=stdout.decode("utf-8", errors="replace"),
|
|
261
|
+
stderr=stderr.decode("utf-8", errors="replace"),
|
|
262
|
+
exit_code=process.returncode or 0
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
except asyncio.TimeoutError:
|
|
266
|
+
# CRITICAL: Kill process on timeout to prevent zombie processes
|
|
267
|
+
if process.returncode is None:
|
|
268
|
+
process.kill()
|
|
269
|
+
await process.wait()
|
|
270
|
+
|
|
271
|
+
error_msg = f"Command exceeded {timeout}s timeout"
|
|
272
|
+
logger.error(
|
|
273
|
+
error_msg,
|
|
274
|
+
extra={
|
|
275
|
+
"component": "terminal_executor",
|
|
276
|
+
"command": command,
|
|
277
|
+
"timeout": timeout
|
|
278
|
+
}
|
|
279
|
+
)
|
|
280
|
+
raise TimeoutError(error_msg)
|
|
281
|
+
|
|
282
|
+
def _validate_command(self, command: str) -> None:
|
|
283
|
+
"""
|
|
284
|
+
Validate command against allowlist.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
command: Command to validate
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
SecurityError: If command is not in ALLOWED_COMMANDS
|
|
291
|
+
"""
|
|
292
|
+
if command not in self._allowed_commands:
|
|
293
|
+
error_msg = f"Command '{command}' not allowed"
|
|
294
|
+
logger.error(
|
|
295
|
+
f"Security violation: {error_msg}",
|
|
296
|
+
extra={
|
|
297
|
+
"component": "terminal_executor",
|
|
298
|
+
"severity": "SECURITY_VIOLATION",
|
|
299
|
+
"command": command,
|
|
300
|
+
"allowed_commands": list(self._allowed_commands),
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
raise SecurityError(error_msg)
|
|
304
|
+
|
|
305
|
+
def _validate_paths(self, args: List[str]) -> None:
|
|
306
|
+
"""
|
|
307
|
+
Validate file paths in arguments against allowlist.
|
|
308
|
+
|
|
309
|
+
This method checks each argument to see if it looks like a file path
|
|
310
|
+
(starts with "./" or "/"). If so, it validates that the path starts
|
|
311
|
+
with one of the allowed path prefixes.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
args: Command arguments to validate
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
SecurityError: If any path argument is not in ALLOWED_PATHS
|
|
318
|
+
|
|
319
|
+
Security Notes:
|
|
320
|
+
- Uses startswith() to allow subdirectories of allowed paths
|
|
321
|
+
- Empty args list is allowed (no paths to validate)
|
|
322
|
+
- Non-path arguments (flags, values) are ignored
|
|
323
|
+
- Absolute paths starting with /tmp or /var/folders are allowed (for tests)
|
|
324
|
+
"""
|
|
325
|
+
from pathlib import Path
|
|
326
|
+
|
|
327
|
+
for arg in args:
|
|
328
|
+
# Check if this argument is a file path
|
|
329
|
+
if arg.startswith("./") or arg.startswith("/"):
|
|
330
|
+
# Allow test directories (pytest tmp_path)
|
|
331
|
+
# macOS: /private/var/folders/, Linux: /tmp/
|
|
332
|
+
if (arg.startswith("/tmp/") or
|
|
333
|
+
arg.startswith("/var/folders/") or
|
|
334
|
+
arg.startswith("/private/var/folders/")):
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
# Validate path is in allowed directories
|
|
338
|
+
if not any(arg.startswith(allowed) for allowed in self._allowed_path_prefixes):
|
|
339
|
+
error_msg = f"Path '{arg}' not allowed"
|
|
340
|
+
logger.error(
|
|
341
|
+
f"Security violation: {error_msg}",
|
|
342
|
+
extra={
|
|
343
|
+
"component": "terminal_executor",
|
|
344
|
+
"severity": "SECURITY_VIOLATION",
|
|
345
|
+
"path": arg,
|
|
346
|
+
"allowed_paths": list(self._allowed_path_prefixes),
|
|
347
|
+
},
|
|
348
|
+
)
|
|
349
|
+
raise SecurityError(error_msg)
|
|
350
|
+
|
|
351
|
+
def _truncate_output(self, output: bytes, stream_name: str) -> bytes:
|
|
352
|
+
"""
|
|
353
|
+
Truncate large output to prevent memory exhaustion.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
output: Raw output bytes from subprocess
|
|
357
|
+
stream_name: Name of stream for logging ("stdout" or "stderr")
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
Truncated output bytes (original if under limit)
|
|
361
|
+
|
|
362
|
+
Notes:
|
|
363
|
+
- Maximum output size: 1MB per stream
|
|
364
|
+
- Truncated outputs include warning message
|
|
365
|
+
- Truncation is logged at WARNING level
|
|
366
|
+
"""
|
|
367
|
+
MAX_OUTPUT_SIZE = 1024 * 1024 # 1MB
|
|
368
|
+
|
|
369
|
+
if len(output) > MAX_OUTPUT_SIZE:
|
|
370
|
+
logger.warning(
|
|
371
|
+
f"Truncating {stream_name}: {len(output)} bytes -> {MAX_OUTPUT_SIZE} bytes",
|
|
372
|
+
extra={
|
|
373
|
+
"component": "terminal_executor",
|
|
374
|
+
"stream": stream_name,
|
|
375
|
+
"original_size": len(output),
|
|
376
|
+
"truncated_size": MAX_OUTPUT_SIZE
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
return output[:MAX_OUTPUT_SIZE] + b"\n[Output truncated at 1MB limit]"
|
|
380
|
+
|
|
381
|
+
return output
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared text normalization for fuzzy match paths (patch + replace_in_file)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_unicode_punctuation(s: str) -> str:
|
|
7
|
+
"""Map common smart quotes/dashes/nbsp to ASCII equivalents for matching."""
|
|
8
|
+
return (
|
|
9
|
+
s.replace("\u2018", "'")
|
|
10
|
+
.replace("\u2019", "'")
|
|
11
|
+
.replace("\u201a", "'")
|
|
12
|
+
.replace("\u201b", "'")
|
|
13
|
+
.replace("\u201c", '"')
|
|
14
|
+
.replace("\u201d", '"')
|
|
15
|
+
.replace("\u201e", '"')
|
|
16
|
+
.replace("\u201f", '"')
|
|
17
|
+
.replace("\u2010", "-")
|
|
18
|
+
.replace("\u2011", "-")
|
|
19
|
+
.replace("\u2012", "-")
|
|
20
|
+
.replace("\u2013", "-")
|
|
21
|
+
.replace("\u2014", "-")
|
|
22
|
+
.replace("\u2015", "-")
|
|
23
|
+
.replace("\u2026", "...")
|
|
24
|
+
.replace("\u00a0", " ")
|
|
25
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tool execution result types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from monkeybot.core.types.content_blocks import ContentBlock, Text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ToolExecutionResult:
|
|
12
|
+
blocks: list[ContentBlock]
|
|
13
|
+
error: str | None = None
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def ok_text(text: str) -> ToolExecutionResult:
|
|
17
|
+
return ToolExecutionResult(blocks=[Text(text=text)])
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def ok_blocks(blocks: list[ContentBlock]) -> ToolExecutionResult:
|
|
21
|
+
return ToolExecutionResult(blocks=list(blocks))
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def err(message: str) -> ToolExecutionResult:
|
|
25
|
+
return ToolExecutionResult(blocks=[Text(text=message)], error=message)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def unwrap_tool_execution_result(result: ToolExecutionResult) -> tuple[str | None, str | None]:
|
|
29
|
+
"""Collapse blocks to ``(text, None)`` or ``(None, error)`` for text-only callers."""
|
|
30
|
+
if result.error is not None:
|
|
31
|
+
return None, result.error
|
|
32
|
+
parts = [b.text for b in result.blocks if isinstance(b, Text)]
|
|
33
|
+
return "".join(parts), None
|