Merge remote-tracking branch 'origin/main' into feature/queen-worker-comm

This commit is contained in:
Richard Tang
2026-03-02 15:39:15 -08:00
14 changed files with 529 additions and 31 deletions
+1 -1
View File
@@ -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
View File
@@ -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)
+18 -5
View File
@@ -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",
}
)
+3 -2
View File
@@ -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:
+47 -1
View File
@@ -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 (
+17 -2
View File
@@ -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:
+26 -7
View File
@@ -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(
+6 -3
View File
@@ -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)
+368
View File
@@ -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
View File
@@ -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
View File
@@ -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
+22 -1
View File
@@ -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}"})