Merge remote-tracking branch 'origin/main' into feature/queen-worker-comm
This commit is contained in:
+1
-1
@@ -64,7 +64,7 @@ To use the agent builder with Claude Desktop or other MCP clients, add this to y
|
||||
"agent-builder": {
|
||||
"command": "python",
|
||||
"args": ["-m", "framework.mcp.agent_builder_server"],
|
||||
"cwd": "/path/to/goal-agent"
|
||||
"cwd": "/path/to/hive/core"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -15,6 +15,7 @@ import base64
|
||||
import hashlib
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import secrets
|
||||
import subprocess
|
||||
@@ -150,8 +151,9 @@ def save_credentials(token_data: dict, account_id: str) -> None:
|
||||
if "id_token" in token_data:
|
||||
auth_data["tokens"]["id_token"] = token_data["id_token"]
|
||||
|
||||
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CODEX_AUTH_FILE, "w") as f:
|
||||
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd = os.open(CODEX_AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(auth_data, f, indent=2)
|
||||
|
||||
|
||||
|
||||
@@ -562,16 +562,29 @@ def _validate_agent_path(agent_path: str) -> tuple[Path | None, str | None]:
|
||||
path = Path(agent_path)
|
||||
|
||||
# Resolve relative paths against project root (not MCP server's cwd)
|
||||
if not path.is_absolute() and not path.exists():
|
||||
resolved = _PROJECT_ROOT / path
|
||||
if resolved.exists():
|
||||
path = resolved
|
||||
if not path.is_absolute():
|
||||
path = _PROJECT_ROOT / path
|
||||
|
||||
# Restrict to allowed directories BEFORE checking existence to prevent
|
||||
# leaking whether arbitrary filesystem paths exist on disk.
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
try:
|
||||
path = validate_agent_path(path)
|
||||
except ValueError:
|
||||
return None, json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": "agent_path must be inside an allowed directory "
|
||||
"(exports/, examples/, or ~/.hive/agents/)",
|
||||
}
|
||||
)
|
||||
|
||||
if not path.exists():
|
||||
return None, json.dumps(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Agent path not found: {path}",
|
||||
"error": f"Agent path not found: {agent_path}",
|
||||
"hint": "Run export_graph to create an agent in exports/ first",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -322,8 +322,9 @@ def _save_refreshed_codex_credentials(auth_data: dict, token_data: dict) -> None
|
||||
auth_data["tokens"] = tokens
|
||||
auth_data["last_refresh"] = datetime.now(UTC).isoformat()
|
||||
|
||||
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(CODEX_AUTH_FILE, "w") as f:
|
||||
CODEX_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd = os.open(CODEX_AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(auth_data, f, indent=2)
|
||||
logger.debug("Codex credentials refreshed successfully")
|
||||
except (OSError, KeyError) as exc:
|
||||
|
||||
@@ -11,6 +11,52 @@ from framework.server.session_manager import Session, SessionManager
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Anchor to the repository root so allowed roots are independent of CWD.
|
||||
# app.py lives at core/framework/server/app.py, so four .parent calls
|
||||
# reach the repo root where exports/ and examples/ live.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
_ALLOWED_AGENT_ROOTS: tuple[Path, ...] | None = None
|
||||
|
||||
|
||||
def _get_allowed_agent_roots() -> tuple[Path, ...]:
|
||||
"""Return resolved allowed root directories for agent loading.
|
||||
|
||||
Roots are anchored to the repository root (derived from ``__file__``)
|
||||
so the allowlist is correct regardless of the process's working
|
||||
directory.
|
||||
"""
|
||||
global _ALLOWED_AGENT_ROOTS
|
||||
if _ALLOWED_AGENT_ROOTS is None:
|
||||
_ALLOWED_AGENT_ROOTS = (
|
||||
(_REPO_ROOT / "exports").resolve(),
|
||||
(_REPO_ROOT / "examples").resolve(),
|
||||
(Path.home() / ".hive" / "agents").resolve(),
|
||||
)
|
||||
return _ALLOWED_AGENT_ROOTS
|
||||
|
||||
|
||||
def validate_agent_path(agent_path: str | Path) -> Path:
|
||||
"""Validate that an agent path resolves inside an allowed directory.
|
||||
|
||||
Prevents arbitrary code execution via ``importlib.import_module`` by
|
||||
restricting agent loading to known safe directories: ``exports/``,
|
||||
``examples/``, and ``~/.hive/agents/``.
|
||||
|
||||
Returns the resolved ``Path`` on success.
|
||||
|
||||
Raises:
|
||||
ValueError: If the path is outside all allowed roots.
|
||||
"""
|
||||
resolved = Path(agent_path).expanduser().resolve()
|
||||
for root in _get_allowed_agent_roots():
|
||||
if resolved.is_relative_to(root) and resolved != root:
|
||||
return resolved
|
||||
raise ValueError(
|
||||
"agent_path must be inside an allowed directory (exports/, examples/, or ~/.hive/agents/)"
|
||||
)
|
||||
|
||||
|
||||
def safe_path_segment(value: str) -> str:
|
||||
"""Validate a URL path parameter is a safe filesystem name.
|
||||
|
||||
@@ -18,7 +64,7 @@ def safe_path_segment(value: str) -> str:
|
||||
traversal sequences. aiohttp decodes ``%2F`` inside route params,
|
||||
so a raw ``{session_id}`` can contain ``/`` or ``..`` after decoding.
|
||||
"""
|
||||
if "/" in value or "\\" in value or ".." in value:
|
||||
if not value or value == "." or "/" in value or "\\" in value or ".." in value:
|
||||
raise web.HTTPBadRequest(reason="Invalid path parameter")
|
||||
return value
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from pydantic import SecretStr
|
||||
|
||||
from framework.credentials.models import CredentialKey, CredentialObject
|
||||
from framework.credentials.store import CredentialStore
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,6 +129,11 @@ async def handle_check_agent(request: web.Request) -> web.Response:
|
||||
if not agent_path:
|
||||
return web.json_response({"error": "agent_path is required"}, status=400)
|
||||
|
||||
try:
|
||||
agent_path = str(validate_agent_path(agent_path))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
try:
|
||||
from framework.credentials.setup import load_agent_nodes
|
||||
from framework.credentials.validation import (
|
||||
|
||||
@@ -93,11 +93,23 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
|
||||
"worker_loaded",
|
||||
}
|
||||
|
||||
client_disconnected = asyncio.Event()
|
||||
|
||||
async def on_event(event) -> None:
|
||||
"""Push event dict into queue; drop non-critical events if full."""
|
||||
if client_disconnected.is_set():
|
||||
return
|
||||
|
||||
evt_dict = event.to_dict()
|
||||
if evt_dict.get("type") in _CRITICAL_EVENTS:
|
||||
await queue.put(evt_dict) # block rather than drop
|
||||
try:
|
||||
queue.put_nowait(evt_dict)
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(
|
||||
"SSE client queue full on critical event; disconnecting session='%s'",
|
||||
session.id,
|
||||
)
|
||||
client_disconnected.set()
|
||||
else:
|
||||
try:
|
||||
queue.put_nowait(evt_dict)
|
||||
@@ -121,7 +133,7 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
|
||||
event_count = 0
|
||||
close_reason = "unknown"
|
||||
try:
|
||||
while True:
|
||||
while not client_disconnected.is_set():
|
||||
try:
|
||||
data = await asyncio.wait_for(queue.get(), timeout=KEEPALIVE_INTERVAL)
|
||||
await sse.send_event(data)
|
||||
@@ -138,6 +150,9 @@ async def handle_events(request: web.Request) -> web.StreamResponse:
|
||||
except Exception as exc:
|
||||
close_reason = f"error: {exc}"
|
||||
break
|
||||
|
||||
if client_disconnected.is_set() and close_reason == "unknown":
|
||||
close_reason = "slow_client"
|
||||
except asyncio.CancelledError:
|
||||
close_reason = "cancelled"
|
||||
finally:
|
||||
|
||||
@@ -30,7 +30,12 @@ from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from framework.server.app import resolve_session, safe_path_segment, sessions_dir
|
||||
from framework.server.app import (
|
||||
resolve_session,
|
||||
safe_path_segment,
|
||||
sessions_dir,
|
||||
validate_agent_path,
|
||||
)
|
||||
from framework.server.session_manager import SessionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -118,6 +123,12 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
model = body.get("model")
|
||||
initial_prompt = body.get("initial_prompt")
|
||||
|
||||
if agent_path:
|
||||
try:
|
||||
agent_path = str(validate_agent_path(agent_path))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
try:
|
||||
if agent_path:
|
||||
# One-step: create session + load worker
|
||||
@@ -143,14 +154,17 @@ async def handle_create_session(request: web.Request) -> web.Response:
|
||||
status=409,
|
||||
)
|
||||
return web.json_response({"error": msg}, status=409)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except FileNotFoundError:
|
||||
return web.json_response(
|
||||
{"error": f"Agent not found: {agent_path or 'no path'}"},
|
||||
status=404,
|
||||
)
|
||||
except Exception as e:
|
||||
resp = _credential_error_response(e, agent_path)
|
||||
if resp is not None:
|
||||
return resp
|
||||
logger.exception("Error creating session: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
return web.json_response(_session_to_live_dict(session), status=201)
|
||||
|
||||
@@ -236,6 +250,11 @@ async def handle_load_worker(request: web.Request) -> web.Response:
|
||||
if not agent_path:
|
||||
return web.json_response({"error": "agent_path is required"}, status=400)
|
||||
|
||||
try:
|
||||
agent_path = str(validate_agent_path(agent_path))
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
worker_id = body.get("worker_id")
|
||||
model = body.get("model")
|
||||
|
||||
@@ -248,14 +267,14 @@ async def handle_load_worker(request: web.Request) -> web.Response:
|
||||
)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=409)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"error": str(e)}, status=404)
|
||||
except FileNotFoundError:
|
||||
return web.json_response({"error": f"Agent not found: {agent_path}"}, status=404)
|
||||
except Exception as e:
|
||||
resp = _credential_error_response(e, agent_path)
|
||||
if resp is not None:
|
||||
return resp
|
||||
logger.exception("Error loading worker: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
return web.json_response({"error": "Internal server error"}, status=500)
|
||||
|
||||
return web.json_response(_session_to_live_dict(session))
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from framework.credentials.models import CredentialError
|
||||
from framework.credentials.validation import validate_agent_credentials
|
||||
from framework.runtime.event_bus import AgentEvent, EventType
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from framework.runner.tool_registry import ToolRegistry
|
||||
@@ -798,9 +799,12 @@ def register_queen_lifecycle_tools(
|
||||
logger.error("Failed to unload existing worker: %s", e, exc_info=True)
|
||||
return json.dumps({"error": f"Failed to unload existing worker: {e}"})
|
||||
|
||||
resolved_path = Path(agent_path).resolve()
|
||||
try:
|
||||
resolved_path = validate_agent_path(agent_path)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
if not resolved_path.exists():
|
||||
return json.dumps({"error": f"Agent path does not exist: {resolved_path}"})
|
||||
return json.dumps({"error": f"Agent path does not exist: {agent_path}"})
|
||||
|
||||
try:
|
||||
updated_session = await session_manager.load_worker(
|
||||
|
||||
@@ -18,7 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -48,10 +47,14 @@ def register_graph_tools(registry: ToolRegistry, runtime: AgentRuntime) -> int:
|
||||
"""
|
||||
from framework.runner.runner import AgentRunner
|
||||
from framework.runtime.execution_stream import EntryPointSpec
|
||||
from framework.server.app import validate_agent_path
|
||||
|
||||
path = Path(agent_path).resolve()
|
||||
try:
|
||||
path = validate_agent_path(agent_path)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
if not path.exists():
|
||||
return json.dumps({"error": f"Agent path does not exist: {path}"})
|
||||
return json.dumps({"error": f"Agent path does not exist: {agent_path}"})
|
||||
|
||||
try:
|
||||
runner = AgentRunner.load(path)
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Tests for validate_agent_path() and _get_allowed_agent_roots().
|
||||
|
||||
Verifies the allowlist-based path validation that prevents arbitrary code
|
||||
execution via importlib.import_module() (Issue #5471).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from framework.server.app import (
|
||||
_get_allowed_agent_roots,
|
||||
create_app,
|
||||
validate_agent_path,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reset_allowed_roots():
|
||||
"""Reset the cached _ALLOWED_AGENT_ROOTS so tests start fresh."""
|
||||
import framework.server.app as app_module
|
||||
|
||||
app_module._ALLOWED_AGENT_ROOTS = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_allowed_agent_roots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAllowedAgentRoots:
|
||||
def setup_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def teardown_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def test_returns_tuple(self):
|
||||
roots = _get_allowed_agent_roots()
|
||||
assert isinstance(roots, tuple), f"Expected tuple, got {type(roots).__name__}"
|
||||
|
||||
def test_contains_three_roots(self):
|
||||
roots = _get_allowed_agent_roots()
|
||||
assert len(roots) == 3
|
||||
|
||||
def test_cached_on_repeated_calls(self):
|
||||
first = _get_allowed_agent_roots()
|
||||
second = _get_allowed_agent_roots()
|
||||
assert first is second
|
||||
|
||||
def test_roots_are_resolved_paths(self):
|
||||
for root in _get_allowed_agent_roots():
|
||||
assert root.is_absolute()
|
||||
# A resolved path has no '..' components
|
||||
assert ".." not in root.parts
|
||||
|
||||
def test_roots_anchored_to_repo_not_cwd(self):
|
||||
"""exports/ and examples/ should be relative to the repo root
|
||||
(derived from __file__), not the process CWD."""
|
||||
from framework.server.app import _REPO_ROOT
|
||||
|
||||
roots = _get_allowed_agent_roots()
|
||||
exports_root, examples_root = roots[0], roots[1]
|
||||
assert exports_root == (_REPO_ROOT / "exports").resolve()
|
||||
assert examples_root == (_REPO_ROOT / "examples").resolve()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_agent_path: positive cases (should return resolved Path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateAgentPathPositive:
|
||||
def setup_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def teardown_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def test_path_inside_exports(self, tmp_path):
|
||||
with patch("framework.server.app._ALLOWED_AGENT_ROOTS", None):
|
||||
import framework.server.app as app_module
|
||||
|
||||
agent_dir = tmp_path / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (tmp_path,)
|
||||
result = validate_agent_path(str(agent_dir))
|
||||
assert result == agent_dir.resolve()
|
||||
|
||||
def test_path_inside_examples(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
examples_root = tmp_path / "examples"
|
||||
examples_root.mkdir()
|
||||
agent_dir = examples_root / "some_agent"
|
||||
agent_dir.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (examples_root,)
|
||||
result = validate_agent_path(str(agent_dir))
|
||||
assert result == agent_dir.resolve()
|
||||
|
||||
def test_path_inside_hive_agents(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
hive_root = tmp_path / ".hive" / "agents"
|
||||
hive_root.mkdir(parents=True)
|
||||
agent_dir = hive_root / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (hive_root,)
|
||||
result = validate_agent_path(str(agent_dir))
|
||||
assert result == agent_dir.resolve()
|
||||
|
||||
def test_returns_path_object(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (tmp_path,)
|
||||
result = validate_agent_path(str(agent_dir))
|
||||
assert isinstance(result, Path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_agent_path: negative cases (should raise ValueError)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateAgentPathNegative:
|
||||
def setup_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def teardown_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def _set_roots(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
exports = tmp_path / "exports"
|
||||
exports.mkdir(exist_ok=True)
|
||||
app_module._ALLOWED_AGENT_ROOTS = (exports,)
|
||||
|
||||
def test_absolute_path_outside_roots(self, tmp_path):
|
||||
self._set_roots(tmp_path)
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path("/tmp/evil")
|
||||
|
||||
def test_traversal_escape(self, tmp_path):
|
||||
self._set_roots(tmp_path)
|
||||
exports = tmp_path / "exports"
|
||||
traversal = str(exports / ".." / ".." / "tmp" / "evil")
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path(traversal)
|
||||
|
||||
def test_sibling_directory_name(self, tmp_path):
|
||||
self._set_roots(tmp_path)
|
||||
# "exports-evil" is NOT a child of "exports"
|
||||
sibling = tmp_path / "exports-evil" / "agent"
|
||||
sibling.mkdir(parents=True)
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path(str(sibling))
|
||||
|
||||
def test_empty_string(self, tmp_path):
|
||||
self._set_roots(tmp_path)
|
||||
# Empty string resolves to CWD, which is outside the allowed roots
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path("")
|
||||
|
||||
def test_home_directory(self, tmp_path):
|
||||
self._set_roots(tmp_path)
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path("~")
|
||||
|
||||
def test_root(self, tmp_path):
|
||||
self._set_roots(tmp_path)
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path("/")
|
||||
|
||||
def test_null_byte(self, tmp_path):
|
||||
"""Null bytes in paths must be rejected (pathlib raises ValueError)."""
|
||||
self._set_roots(tmp_path)
|
||||
with pytest.raises(ValueError):
|
||||
validate_agent_path("exports/\x00evil")
|
||||
|
||||
def test_symlink_escape(self, tmp_path):
|
||||
"""A symlink inside an allowed root pointing outside must be rejected."""
|
||||
import framework.server.app as app_module
|
||||
|
||||
allowed = tmp_path / "exports"
|
||||
allowed.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
link = allowed / "sneaky"
|
||||
link.symlink_to(outside)
|
||||
app_module._ALLOWED_AGENT_ROOTS = (allowed,)
|
||||
# The symlink resolves to outside the allowed root
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path(str(link))
|
||||
|
||||
def test_root_itself_rejected(self, tmp_path):
|
||||
"""Passing the exact root directory itself should be rejected."""
|
||||
import framework.server.app as app_module
|
||||
|
||||
allowed = tmp_path / "exports"
|
||||
allowed.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (allowed,)
|
||||
with pytest.raises(ValueError, match="allowed directory"):
|
||||
validate_agent_path(str(allowed))
|
||||
|
||||
def test_tilde_expansion(self, tmp_path, monkeypatch):
|
||||
"""Paths with ~ prefix should be expanded via expanduser()."""
|
||||
import framework.server.app as app_module
|
||||
|
||||
# Set both HOME (POSIX) and USERPROFILE (Windows) so
|
||||
# Path.expanduser() resolves ~ to tmp_path on all platforms.
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
|
||||
hive_agents = tmp_path / ".hive" / "agents"
|
||||
hive_agents.mkdir(parents=True)
|
||||
agent_dir = hive_agents / "my_agent"
|
||||
agent_dir.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (hive_agents,)
|
||||
|
||||
result = validate_agent_path("~/.hive/agents/my_agent")
|
||||
assert result == agent_dir.resolve()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ALLOWED_AGENT_ROOTS immutability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAllowedRootsImmutability:
|
||||
def setup_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def teardown_method(self):
|
||||
_reset_allowed_roots()
|
||||
|
||||
def test_is_tuple_not_list(self):
|
||||
roots = _get_allowed_agent_roots()
|
||||
assert isinstance(roots, tuple), "Should be tuple to prevent mutation"
|
||||
assert not isinstance(roots, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: HTTP endpoints reject malicious paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHTTPEndpointsRejectMaliciousPaths:
|
||||
"""Test that HTTP route handlers return 400 for paths outside allowed roots."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_rejects_outside_path(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
exports = tmp_path / "exports"
|
||||
exports.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (exports,)
|
||||
try:
|
||||
app = create_app()
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions",
|
||||
json={"agent_path": "/tmp/evil"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert "allowed directory" in body["error"]
|
||||
finally:
|
||||
_reset_allowed_roots()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_rejects_traversal(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
exports = tmp_path / "exports"
|
||||
exports.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (exports,)
|
||||
try:
|
||||
app = create_app()
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions",
|
||||
json={"agent_path": "exports/../../tmp/evil"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert "allowed directory" in body["error"]
|
||||
finally:
|
||||
_reset_allowed_roots()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_worker_rejects_outside_path(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
exports = tmp_path / "exports"
|
||||
exports.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (exports,)
|
||||
try:
|
||||
app = create_app()
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
# First create a queen-only session
|
||||
create_resp = await client.post("/api/sessions", json={})
|
||||
if create_resp.status != 201:
|
||||
pytest.skip(f"Cannot create queen-only session (status={create_resp.status})")
|
||||
session_id = (await create_resp.json())["session_id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/sessions/{session_id}/worker",
|
||||
json={"agent_path": "/tmp/evil"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert "allowed directory" in body["error"]
|
||||
finally:
|
||||
_reset_allowed_roots()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_agent_credentials_rejects_traversal(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
exports = tmp_path / "exports"
|
||||
exports.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (exports,)
|
||||
try:
|
||||
app = create_app()
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/credentials/check-agent",
|
||||
json={"agent_path": "exports/../../etc/passwd"},
|
||||
)
|
||||
assert resp.status == 400
|
||||
body = await resp.json()
|
||||
assert "allowed directory" in body["error"]
|
||||
finally:
|
||||
_reset_allowed_roots()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_message_does_not_leak_resolved_path(self, tmp_path):
|
||||
import framework.server.app as app_module
|
||||
|
||||
exports = tmp_path / "exports"
|
||||
exports.mkdir()
|
||||
app_module._ALLOWED_AGENT_ROOTS = (exports,)
|
||||
try:
|
||||
app = create_app()
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
resp = await client.post(
|
||||
"/api/sessions",
|
||||
json={"agent_path": "/tmp/evil"},
|
||||
)
|
||||
body = await resp.json()
|
||||
# The error message should not contain the resolved absolute path
|
||||
# It should use the generic allowlist message
|
||||
assert "/tmp/evil" not in body["error"]
|
||||
assert "allowed directory" in body["error"]
|
||||
finally:
|
||||
_reset_allowed_roots()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
+1
-1
@@ -760,7 +760,7 @@ $ModelChoices = @{
|
||||
)
|
||||
gemini = @(
|
||||
@{ Id = "gemini-3-flash-preview"; Label = "Gemini 3 Flash - Fast (recommended)"; MaxTokens = 8192 },
|
||||
@{ Id = "gemini-3-pro-preview"; Label = "Gemini 3 Pro - Best quality"; MaxTokens = 8192 }
|
||||
@{ Id = "gemini-3.1-pro-preview"; Label = "Gemini 3.1 Pro - Best quality"; MaxTokens = 8192 }
|
||||
)
|
||||
groq = @(
|
||||
@{ Id = "moonshotai/kimi-k2-instruct-0905"; Label = "Kimi K2 - Best quality (recommended)"; MaxTokens = 8192 },
|
||||
|
||||
+4
-4
@@ -427,7 +427,7 @@ if [ "$USE_ASSOC_ARRAYS" = true ]; then
|
||||
["openai:0"]="gpt-5.2"
|
||||
["openai:1"]="gpt-5-mini"
|
||||
["gemini:0"]="gemini-3-flash-preview"
|
||||
["gemini:1"]="gemini-3-pro-preview"
|
||||
["gemini:1"]="gemini-3.1-pro-preview"
|
||||
["groq:0"]="moonshotai/kimi-k2-instruct-0905"
|
||||
["groq:1"]="openai/gpt-oss-120b"
|
||||
["cerebras:0"]="zai-glm-4.7"
|
||||
@@ -442,7 +442,7 @@ if [ "$USE_ASSOC_ARRAYS" = true ]; then
|
||||
["openai:0"]="GPT-5.2 - Most capable (recommended)"
|
||||
["openai:1"]="GPT-5 Mini - Fast + cheap"
|
||||
["gemini:0"]="Gemini 3 Flash - Fast (recommended)"
|
||||
["gemini:1"]="Gemini 3 Pro - Best quality"
|
||||
["gemini:1"]="Gemini 3.1 Pro - Best quality"
|
||||
["groq:0"]="Kimi K2 - Best quality (recommended)"
|
||||
["groq:1"]="GPT-OSS 120B - Fast reasoning"
|
||||
["cerebras:0"]="ZAI-GLM 4.7 - Best quality (recommended)"
|
||||
@@ -552,8 +552,8 @@ else
|
||||
# Model choices per provider - flat parallel arrays with provider offsets
|
||||
# Provider order: anthropic(4), openai(2), gemini(2), groq(2), cerebras(2)
|
||||
MC_PROVIDERS=(anthropic anthropic anthropic anthropic openai openai gemini gemini groq groq cerebras cerebras)
|
||||
MC_IDS=("claude-opus-4-6" "claude-sonnet-4-5-20250929" "claude-sonnet-4-20250514" "claude-haiku-4-5-20251001" "gpt-5.2" "gpt-5-mini" "gemini-3-flash-preview" "gemini-3-pro-preview" "moonshotai/kimi-k2-instruct-0905" "openai/gpt-oss-120b" "zai-glm-4.7" "qwen3-235b-a22b-instruct-2507")
|
||||
MC_LABELS=("Opus 4.6 - Most capable (recommended)" "Sonnet 4.5 - Best balance" "Sonnet 4 - Fast + capable" "Haiku 4.5 - Fast + cheap" "GPT-5.2 - Most capable (recommended)" "GPT-5 Mini - Fast + cheap" "Gemini 3 Flash - Fast (recommended)" "Gemini 3 Pro - Best quality" "Kimi K2 - Best quality (recommended)" "GPT-OSS 120B - Fast reasoning" "ZAI-GLM 4.7 - Best quality (recommended)" "Qwen3 235B - Frontier reasoning")
|
||||
MC_IDS=("claude-opus-4-6" "claude-sonnet-4-5-20250929" "claude-sonnet-4-20250514" "claude-haiku-4-5-20251001" "gpt-5.2" "gpt-5-mini" "gemini-3-flash-preview" "gemini-3.1-pro-preview" "moonshotai/kimi-k2-instruct-0905" "openai/gpt-oss-120b" "zai-glm-4.7" "qwen3-235b-a22b-instruct-2507")
|
||||
MC_LABELS=("Opus 4.6 - Most capable (recommended)" "Sonnet 4.5 - Best balance" "Sonnet 4 - Fast + capable" "Haiku 4.5 - Fast + cheap" "GPT-5.2 - Most capable (recommended)" "GPT-5 Mini - Fast + cheap" "Gemini 3 Flash - Fast (recommended)" "Gemini 3.1 Pro - Best quality" "Kimi K2 - Best quality (recommended)" "GPT-OSS 120B - Fast reasoning" "ZAI-GLM 4.7 - Best quality (recommended)" "Qwen3 235B - Frontier reasoning")
|
||||
MC_MAXTOKENS=(32768 16384 8192 8192 16384 16384 8192 8192 8192 8192 8192 8192)
|
||||
|
||||
# Helper: get number of model choices for a provider
|
||||
|
||||
@@ -453,7 +453,28 @@ def validate_agent_tools(agent_path: str) -> str:
|
||||
Returns:
|
||||
JSON with validation result: pass/fail, missing tools per node, available tools
|
||||
"""
|
||||
resolved = _resolve_path(agent_path)
|
||||
try:
|
||||
resolved = _resolve_path(agent_path)
|
||||
except ValueError:
|
||||
return json.dumps({"error": "Access denied: path is outside the project root."})
|
||||
|
||||
# Restrict to allowed directories to prevent arbitrary code execution
|
||||
# via importlib.import_module() below.
|
||||
try:
|
||||
from framework.server.app import validate_agent_path
|
||||
except ImportError:
|
||||
return json.dumps({"error": "Cannot validate agent path: framework package not available"})
|
||||
|
||||
try:
|
||||
resolved = str(validate_agent_path(resolved))
|
||||
except ValueError:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "agent_path must be inside an allowed directory "
|
||||
"(exports/, examples/, or ~/.hive/agents/)"
|
||||
}
|
||||
)
|
||||
|
||||
if not os.path.isdir(resolved):
|
||||
return json.dumps({"error": f"Agent directory not found: {agent_path}"})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user