Skip to content

Commit cfefe46

Browse files
committed
claude issues addressed
1 parent ff3d7ff commit cfefe46

File tree

7 files changed

+353
-39
lines changed

7 files changed

+353
-39
lines changed

bluebox/agents/bluebox_agent.py

Lines changed: 16 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from __future__ import annotations
1212

1313
import json
14+
import threading
1415
from concurrent.futures import ThreadPoolExecutor, as_completed
1516
from datetime import datetime
1617
from pathlib import Path
@@ -30,6 +31,7 @@
3031
from bluebox.data_models.llms.vendors import LLMModel, OpenAIModel
3132
from bluebox.data_models.routine.routine import RoutineExecutionRequest, RoutineInfo
3233
from bluebox.utils.code_execution_sandbox import execute_python_sandboxed
34+
from bluebox.utils.infra_utils import read_file_lines
3335
from bluebox.utils.llm_utils import token_optimized
3436
from bluebox.utils.logger import get_logger
3537

@@ -71,7 +73,7 @@ class BlueBoxAgent(AbstractAgent):
7173
7274
## Inspecting the Workspace
7375
- Use `list_workspace_files` to see all files in the workspace (raw/, outputs/, etc.).
74-
- Use `read_workspace_file` to read any file by relative path (e.g. "raw/routine_results_2024-01-15_143052_abc.json" or "outputs/results.csv"). Use optional start_line/end_line to read specific line ranges for large files.
76+
- Use `read_workspace_file` to read any file by relative path (e.g. "raw/25-01-15-143052-routine_result_1.json" or "outputs/results.csv"). Use optional start_line/end_line to read specific line ranges for large files.
7577
7678
## Important Rules
7779
- You ONLY have routine tools, code execution, and file inspection tools. Do not tell the user you can browse, click, type, or interact with web pages directly.
@@ -117,6 +119,8 @@ def __init__(
117119
self._raw_dir = self._workspace_dir / "raw"
118120
self._outputs_dir = self._workspace_dir / "outputs"
119121
self._routine_cache: dict[str, RoutineInfo] = {}
122+
self._execution_counter: int = 0
123+
self._counter_lock = threading.Lock()
120124

121125
super().__init__(
122126
emit_message_callable=emit_message_callable,
@@ -245,9 +249,11 @@ def save_result(result: dict[str, Any]) -> dict[str, Any]:
245249
"""Save a single routine result to a JSON file in raw/."""
246250
try:
247251
self._raw_dir.mkdir(parents=True, exist_ok=True)
248-
rid = result.get("routine_id", "unknown")
249-
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
250-
output_path = self._raw_dir / f"routine_results_{timestamp}_{rid}.json"
252+
with self._counter_lock:
253+
self._execution_counter += 1
254+
idx = self._execution_counter
255+
timestamp = datetime.now().strftime("%y-%m-%d-%H%M%S")
256+
output_path = self._raw_dir / f"{timestamp}-routine_result_{idx}.json"
251257
output_path.write_text(json.dumps(result, indent=2, default=str))
252258
result["output_file"] = str(output_path)
253259
logger.info("Routine result saved to %s", output_path)
@@ -445,37 +451,11 @@ def _read_workspace_file(
445451
# Resolve and validate path stays within workspace
446452
resolved = (self._workspace_dir / path).resolve()
447453
workspace_resolved = self._workspace_dir.resolve()
448-
if not str(resolved).startswith(str(workspace_resolved) + "/") and resolved != workspace_resolved:
449-
return {"error": f"Access denied: '{path}' is outside the workspace directory"}
450-
451-
if not resolved.exists():
452-
return {"error": f"File not found: {path}"}
453-
if not resolved.is_file():
454-
return {"error": f"Not a file: {path}"}
455-
456454
try:
457-
lines = resolved.read_text().splitlines()
458-
except OSError as e:
459-
return {"error": f"Failed to read file: {e}"}
460-
461-
total_lines = len(lines)
462-
463-
# Apply line range
464-
if start_line is not None or end_line is not None:
465-
s = (start_line or 1) - 1 # Convert to 0-based
466-
e = end_line or total_lines
467-
lines = lines[s:e]
468-
line_range = f"lines {s + 1}-{min(e, total_lines)} of {total_lines}"
469-
else:
470-
# Cap output at 200 lines to avoid blowing up context
471-
if total_lines > 200:
472-
lines = lines[:200]
473-
line_range = f"lines 1-200 of {total_lines} (truncated, use start_line/end_line for more)"
474-
else:
475-
line_range = f"all {total_lines} lines"
455+
resolved.relative_to(workspace_resolved)
456+
except ValueError:
457+
return {"error": f"Access denied: '{path}' is outside the workspace directory"}
476458

477-
return {
478-
"path": path,
479-
"line_range": line_range,
480-
"content": "\n".join(lines),
481-
}
459+
result = read_file_lines(resolved, start_line=start_line, end_line=end_line)
460+
result["path"] = path
461+
return result

bluebox/scripts/agent_http_adapter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878

7979
def discover_agent_classes() -> dict[str, type]:
8080
"""Build registry of all available AbstractAgent subclasses by class name."""
81-
from bluebox.agents.super_discovery_agent import RoutineDiscoveryAgentBeta
81+
from bluebox.agents.routine_discovery_agent_beta import RoutineDiscoveryAgentBeta
8282
from bluebox.agents.bluebox_agent import BlueBoxAgent
8383

8484
# Import all specialist modules to trigger __init_subclass__ registration

bluebox/utils/code_execution_sandbox.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import subprocess
2727
import sys
2828
import time
29+
import uuid
2930
from typing import Any
3031

3132
from bluebox.config import Config
@@ -110,6 +111,12 @@ def register_lambda_executor(fn: Any) -> None:
110111
"delattr", "breakpoint", "input", "memoryview",
111112
)
112113

114+
# Sensitive system paths that must never be used as work_dir
115+
SENSITIVE_PATH_PREFIXES: tuple[str, ...] = (
116+
"/etc", "/var", "/usr", "/bin", "/sbin",
117+
"/boot", "/proc", "/sys", "/dev",
118+
)
119+
113120

114121
def _is_docker_available() -> bool:
115122
"""
@@ -200,7 +207,7 @@ def _execute_in_docker(
200207
"""
201208

202209
# Generate unique container name for cleanup on timeout
203-
container_name = f"bluebox-sandbox-{os.getpid()}-{int(time.time() * 1000)}"
210+
container_name = f"bluebox-sandbox-{os.getpid()}-{uuid.uuid4().hex[:8]}"
204211

205212
docker_cmd = [
206213
"docker", "run",
@@ -430,6 +437,12 @@ def execute_python_sandboxed(
430437
if not code:
431438
return {"error": "No code provided"}
432439

440+
# Validate work_dir before passing to any backend
441+
if work_dir:
442+
work_dir = os.path.abspath(work_dir)
443+
if any(work_dir == p or work_dir.startswith(p + os.sep) for p in SENSITIVE_PATH_PREFIXES):
444+
return {"error": f"work_dir points to a sensitive system path: {work_dir}"}
445+
433446
# Check for blocked patterns (allow open() when work_dir is set)
434447
safety_error = check_code_safety(code, allow_file_io=bool(work_dir))
435448
if safety_error:

bluebox/utils/infra_utils.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shutil
88
import zipfile
99
from pathlib import Path
10+
from typing import Any
1011

1112
import requests
1213

@@ -85,6 +86,69 @@ def extract_zip(zip_path: Path, extract_to: Path) -> bool:
8586
return False
8687

8788

89+
def read_file_lines(
90+
file_path: Path,
91+
start_line: int | None = None,
92+
end_line: int | None = None,
93+
max_lines: int = 200,
94+
) -> dict[str, Any]:
95+
"""
96+
Read a text file with optional line range, streaming to avoid loading
97+
the entire file into memory.
98+
99+
Args:
100+
file_path: Resolved path to the file.
101+
start_line: Optional 1-based start line (inclusive).
102+
end_line: Optional 1-based end line (inclusive).
103+
max_lines: Maximum lines to return when no range is specified.
104+
105+
Returns:
106+
Dict with "content", "line_range", or "error" on failure.
107+
"""
108+
if not file_path.exists():
109+
return {"error": f"File not found: {file_path}"}
110+
if not file_path.is_file():
111+
return {"error": f"Not a file: {file_path}"}
112+
113+
has_range = start_line is not None or end_line is not None
114+
s = (start_line or 1) - 1 # 0-based start index
115+
upper = end_line if has_range and end_line is not None else None
116+
117+
lines: list[str] = []
118+
total_lines = 0
119+
try:
120+
with file_path.open("r") as f:
121+
for i, raw in enumerate(f):
122+
total_lines = i + 1
123+
if i >= s and (upper is None or i < upper):
124+
if not has_range and len(lines) >= max_lines:
125+
continue
126+
lines.append(raw.rstrip("\n"))
127+
if upper is not None and total_lines >= upper:
128+
remaining = sum(1 for _ in f)
129+
total_lines += remaining
130+
break
131+
except OSError as e:
132+
return {"error": f"Failed to read file: {e}"}
133+
134+
if has_range:
135+
e = end_line or total_lines
136+
line_range = f"lines {s + 1}-{min(e, total_lines)} of {total_lines}"
137+
else:
138+
if total_lines > max_lines:
139+
line_range = (
140+
f"lines 1-{max_lines} of {total_lines} "
141+
"(truncated, use start_line/end_line for more)"
142+
)
143+
else:
144+
line_range = f"all {total_lines} lines"
145+
146+
return {
147+
"line_range": line_range,
148+
"content": "\n".join(lines),
149+
}
150+
151+
88152
def resolve_glob_patterns(
89153
patterns: list[str],
90154
extensions: set[str] | None = None,

tests/unit/test_code_execution_sandbox.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import os
88
import subprocess
9+
from pathlib import Path
910
from unittest.mock import patch, MagicMock
1011

1112
import pytest
@@ -14,6 +15,7 @@
1415
BLOCKED_MODULES,
1516
BLOCKED_PATTERNS,
1617
BLOCKED_BUILTINS,
18+
SENSITIVE_PATH_PREFIXES,
1719
check_code_safety,
1820
create_safe_builtins,
1921
execute_python_sandboxed,
@@ -1259,6 +1261,42 @@ def test_docker_mode_passes_work_dir(self) -> None:
12591261
sandbox_module._docker_available = None
12601262

12611263

1264+
class TestWorkDirValidation:
1265+
"""Tests for work_dir validation in execute_python_sandboxed."""
1266+
1267+
@pytest.mark.parametrize("prefix", SENSITIVE_PATH_PREFIXES)
1268+
def test_rejects_sensitive_prefix_exact(self, prefix: str) -> None:
1269+
"""Every entry in SENSITIVE_PATH_PREFIXES should be rejected as work_dir."""
1270+
result = execute_python_sandboxed("print('hi')", work_dir=prefix)
1271+
assert "error" in result
1272+
assert "sensitive system path" in result["error"]
1273+
1274+
@pytest.mark.parametrize("prefix", SENSITIVE_PATH_PREFIXES)
1275+
def test_rejects_sensitive_prefix_subdir(self, prefix: str) -> None:
1276+
"""Subdirectories under sensitive prefixes should also be rejected."""
1277+
result = execute_python_sandboxed("print('hi')", work_dir=f"{prefix}/subdir")
1278+
assert "error" in result
1279+
assert "sensitive system path" in result["error"]
1280+
1281+
def test_normalizes_path_with_dotdot(self) -> None:
1282+
"""Paths with '..' that resolve to a sensitive prefix should be rejected."""
1283+
result = execute_python_sandboxed("print('hi')", work_dir="/etc/../etc/nginx")
1284+
assert "error" in result
1285+
assert "sensitive system path" in result["error"]
1286+
1287+
def test_allows_tmp(self, tmp_path: Path) -> None:
1288+
"""A normal temp directory should pass validation."""
1289+
import bluebox.utils.code_execution_sandbox as sandbox_module
1290+
original_mode = sandbox_module.SANDBOX_MODE
1291+
try:
1292+
sandbox_module.SANDBOX_MODE = "blocklist"
1293+
result = execute_python_sandboxed("print('ok')", work_dir=str(tmp_path))
1294+
assert "error" not in result
1295+
assert "ok" in result["output"]
1296+
finally:
1297+
sandbox_module.SANDBOX_MODE = original_mode
1298+
1299+
12621300
class TestDockerExecutionWorkDir:
12631301
"""Tests for Docker execution with work_dir."""
12641302

0 commit comments

Comments
 (0)