Skip to content
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ src/synthorg/
subscribers/ # Concrete settings subscribers (ProviderSettingsSubscriber — rebuilds ModelRouter on strategy change, MemorySettingsSubscriber — advisory logging for memory config)
security/ # SecOps agent, rule engine (soft-allow/hard-deny, fail-closed), audit log, output scanner, output scan response policies (redact/withhold/log-only/autonomy-tiered), risk classifier, risk tier classifier, action type registry, ToolInvoker security integration, progressive trust (4 strategies: disabled/weighted/per-category/milestone), autonomy levels (presets, resolver, change strategy), timeout policies (park/resume)
templates/ # Pre-built company templates, personality presets, and builder
tools/ # Tool registry, built-in tools (file_system/, git, sandbox/, code_runner), git clone SSRF prevention (git_url_validator), MCP bridge (mcp/), role-based access, approval tool (request_human_approval), tool factory (build_default_tools, build_default_tools_from_config)
tools/ # Tool registry, built-in tools (file_system/, git, sandbox/, code_runner), git clone SSRF prevention (git_url_validator), MCP bridge (mcp/), role-based access, approval tool (request_human_approval), tool factory (build_default_tools, build_default_tools_from_config), sandbox factory (sandbox/factory.py: build_sandbox_backends, resolve_sandbox_for_category, cleanup_sandbox_backends -- per-category backend selection from SandboxingConfig)

web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard
src/
Expand Down
3 changes: 3 additions & 0 deletions src/synthorg/observability/events/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@
SANDBOX_HEALTH_CHECK: Final[str] = "sandbox.health_check"
SANDBOX_KILL_FAILED: Final[str] = "sandbox.kill.failed"
SANDBOX_KILL_FALLBACK: Final[str] = "sandbox.kill.fallback"
SANDBOX_FACTORY_BUILT: Final[str] = "sandbox.factory.built"
SANDBOX_FACTORY_RESOLVE: Final[str] = "sandbox.factory.resolve"
SANDBOX_FACTORY_CLEANUP: Final[str] = "sandbox.factory.cleanup"
51 changes: 46 additions & 5 deletions src/synthorg/tools/factory.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Tool factory instantiate built-in workspace tools with config-driven parameters.
"""Tool factory -- instantiate built-in workspace tools with config-driven parameters.

Provides ``build_default_tools`` (core factory) and
``build_default_tools_from_config`` (convenience wrapper that
Expand All @@ -9,6 +9,7 @@

from typing import TYPE_CHECKING

from synthorg.core.enums import ToolCategory
from synthorg.observability import get_logger
from synthorg.observability.events.tool import (
TOOL_FACTORY_BUILT,
Expand All @@ -30,8 +31,13 @@
GitLogTool,
GitStatusTool,
)
from synthorg.tools.sandbox.factory import (
build_sandbox_backends,
resolve_sandbox_for_category,
)

if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path

from synthorg.config.schema import RootConfig
Expand Down Expand Up @@ -132,17 +138,30 @@ def build_default_tools_from_config(
workspace: Path,
config: RootConfig,
sandbox: SandboxBackend | None = None,
sandbox_backends: Mapping[str, SandboxBackend] | None = None,
) -> tuple[BaseTool, ...]:
"""Build default tools using parameters from a ``RootConfig``.

Convenience wrapper that extracts ``config.git_clone`` and
delegates to :func:`build_default_tools`.
``config.sandboxing`` to resolve per-category sandbox backends.

Currently wires the ``VERSION_CONTROL`` category (git tools).
Other categories (e.g. ``CODE_EXECUTION``) will be wired as
their respective tool builders are added to the factory.

Sandbox resolution priority:
1. Explicit *sandbox* -- backward-compat single backend for all tools.
2. Explicit *sandbox_backends* -- per-category resolution via config.
3. Neither -- auto-build backends from ``config.sandboxing``.

Args:
workspace: Absolute path to the agent workspace root.
config: Validated root configuration.
sandbox: Optional sandbox backend for subprocess
isolation (passed to git tools).
sandbox: Optional single sandbox backend (overrides per-category
resolution when provided).
sandbox_backends: Pre-built mapping of backend name to instance.
When provided, per-category resolution uses this map
instead of auto-building backends.

Returns:
Sorted tuple of ``BaseTool`` instances.
Expand All @@ -154,8 +173,30 @@ def build_default_tools_from_config(
TOOL_FACTORY_CONFIG_ENTRY,
source="config",
)

if sandbox is not None:
# Explicit single backend -- backward compat
return build_default_tools(
workspace=workspace,
git_clone_policy=config.git_clone,
sandbox=sandbox,
)

# Per-category resolution
if sandbox_backends is None:
sandbox_backends = build_sandbox_backends(
config=config.sandboxing,
workspace=workspace,
)

vc_sandbox = resolve_sandbox_for_category(
config=config.sandboxing,
backends=sandbox_backends,
category=ToolCategory.VERSION_CONTROL,
)

return build_default_tools(
workspace=workspace,
git_clone_policy=config.git_clone,
sandbox=sandbox,
sandbox=vc_sandbox,
)
8 changes: 8 additions & 0 deletions src/synthorg/tools/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
from .docker_config import DockerSandboxConfig
from .docker_sandbox import DockerSandbox
from .errors import SandboxError, SandboxStartError, SandboxTimeoutError
from .factory import (
build_sandbox_backends,
cleanup_sandbox_backends,
resolve_sandbox_for_category,
)
from .protocol import SandboxBackend
from .result import SandboxResult
from .sandboxing_config import SandboxingConfig
Expand All @@ -20,4 +25,7 @@
"SandboxingConfig",
"SubprocessSandbox",
"SubprocessSandboxConfig",
"build_sandbox_backends",
"cleanup_sandbox_backends",
"resolve_sandbox_for_category",
]
5 changes: 2 additions & 3 deletions src/synthorg/tools/sandbox/docker_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import aiodocker
import aiodocker.containers

from synthorg.core.types import NotBlankStr
from synthorg.observability import get_logger
from synthorg.observability.events.docker import (
DOCKER_CLEANUP,
Expand All @@ -35,8 +36,6 @@
if TYPE_CHECKING:
from collections.abc import Mapping

from synthorg.core.types import NotBlankStr

logger = get_logger(__name__)

_DEFAULT_CONFIG = DockerSandboxConfig()
Expand Down Expand Up @@ -645,4 +644,4 @@ async def health_check(self) -> bool:

def get_backend_type(self) -> NotBlankStr:
"""Return ``'docker'``."""
return "docker"
return NotBlankStr("docker")
Comment on lines 645 to +647

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== NotBlankStr definition =="
rg -n -C2 '^\s*NotBlankStr\s*=' src/synthorg/core/types.py

echo
echo "== get_backend_type implementations =="
rg -n -C2 '\bdef get_backend_type\s*\(' src/synthorg/tools/sandbox

echo
echo "== Constructor-style NotBlankStr usage =="
rg -n '\bNotBlankStr\s*\(' src/synthorg

Repository: Aureliolo/synthorg

Length of output: 9129


Return a plain string instead of calling NotBlankStr as a constructor.

NotBlankStr is a type alias (Annotated[str, ...]), not a callable type. While NotBlankStr("docker") won't fail at runtime, it's incorrect usage. Instead, return the plain string:

def get_backend_type(self) -> NotBlankStr:
    """Return ``'docker'``."""
    return "docker"  # type: ignore[return-value]

Or use cast for clarity:

from typing import cast
return cast(NotBlankStr, "docker")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/tools/sandbox/docker_sandbox.py` around lines 645 - 647, The
get_backend_type function currently constructs NotBlankStr("docker") incorrectly
(NotBlankStr is a type alias/Annotated[str,...]); change it to return a plain
string instead — replace NotBlankStr("docker") with "docker" and annotate the
return to satisfy typing (e.g. add a type ignore comment like `# type:
ignore[return-value]` or use typing.cast(NotBlankStr, "docker")) so
get_backend_type returns a plain str while preserving the NotBlankStr return
annotation.

153 changes: 153 additions & 0 deletions src/synthorg/tools/sandbox/factory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Sandbox backend factory -- build and resolve backends from config.

Provides ``build_sandbox_backends`` to instantiate only the backends
referenced by a ``SandboxingConfig``, ``resolve_sandbox_for_category``
to look up the correct backend for a tool category, and
``cleanup_sandbox_backends`` to release resources.
"""

import asyncio
from types import MappingProxyType
from typing import TYPE_CHECKING

from synthorg.observability import get_logger
from synthorg.observability.events.sandbox import (
SANDBOX_FACTORY_BUILT,
SANDBOX_FACTORY_CLEANUP,
SANDBOX_FACTORY_RESOLVE,
)
from synthorg.tools.sandbox.docker_sandbox import DockerSandbox
from synthorg.tools.sandbox.subprocess_sandbox import SubprocessSandbox

if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path

from synthorg.core.enums import ToolCategory
from synthorg.tools.sandbox.protocol import SandboxBackend
from synthorg.tools.sandbox.sandboxing_config import SandboxingConfig

logger = get_logger(__name__)


def build_sandbox_backends(
*,
config: SandboxingConfig,
workspace: Path,
) -> MappingProxyType[str, SandboxBackend]:
"""Build only the backend instances actually referenced by *config*.

Collects which backend names are needed (the default plus all
override values), then instantiates ``SubprocessSandbox`` and/or
``DockerSandbox`` with their respective sub-configs.

Args:
config: Top-level sandboxing configuration.
workspace: Absolute path to the agent workspace root.

Returns:
A read-only mapping of backend name to backend instance.
Only contains keys for backends that are actually referenced.
"""
needed: set[str] = {config.default_backend}
needed.update(config.overrides.values())

backends: dict[str, SandboxBackend] = {}

if "subprocess" in needed:
backends["subprocess"] = SubprocessSandbox(
config=config.subprocess,
workspace=workspace,
)

if "docker" in needed:
backends["docker"] = DockerSandbox(
config=config.docker,
workspace=workspace,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation uses a series of if statements to build the backend instances. This pattern can become cumbersome to maintain as more backend types are added. A more extensible approach would be to use a registry pattern (a dictionary mapping backend names to their constructor classes). This would centralize the backend registration and make the building logic more generic and concise. The suggested implementation uses a dictionary comprehension, assuming that the attribute name in SandboxingConfig for a backend's specific configuration matches the backend's name (e.g., config.docker for the 'docker' backend), which is the current convention.

Suggested change
backends: dict[str, SandboxBackend] = {}
if "subprocess" in needed:
backends["subprocess"] = SubprocessSandbox(
config=config.subprocess,
workspace=workspace,
)
if "docker" in needed:
backends["docker"] = DockerSandbox(
config=config.docker,
workspace=workspace,
)
backends: dict[str, SandboxBackend] = {
name: builder(config=getattr(config, name), workspace=workspace)
for name, builder in {
"subprocess": SubprocessSandbox,
"docker": DockerSandbox,
}.items()
if name in needed
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

logger.info(
SANDBOX_FACTORY_BUILT,
backends=sorted(backends.keys()),
default=config.default_backend,
override_count=len(config.overrides),
)
return MappingProxyType(backends)
Comment on lines +71 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Refactor build_sandbox_backends to stay within function-size limits.

This function is currently over the project’s 50-line limit; split validation/instantiation into helpers to keep the public function focused.

♻️ Proposed refactor split
+def _collect_needed_backends(config: SandboxingConfig) -> set[str]:
+    needed: set[str] = {config.default_backend}
+    needed.update(config.overrides.values())
+    return needed
+
+def _validate_needed_backends(needed: set[str]) -> None:
+    unknown = needed - _KNOWN_BACKENDS
+    if unknown:
+        msg = (
+            f"Unrecognized sandbox backend name(s): {sorted(unknown)}; "
+            f"known backends: {sorted(_KNOWN_BACKENDS)}"
+        )
+        logger.error(SANDBOX_FACTORY_BUILD_FAILED, error=msg)
+        raise ValueError(msg)
+
 def build_sandbox_backends(
@@
-    needed: set[str] = {config.default_backend}
-    needed.update(config.overrides.values())
-
-    unknown = needed - _KNOWN_BACKENDS
-    if unknown:
-        msg = (
-            f"Unrecognized sandbox backend name(s): {sorted(unknown)}; "
-            f"known backends: {sorted(_KNOWN_BACKENDS)}"
-        )
-        logger.error(SANDBOX_FACTORY_BUILD_FAILED, error=msg)
-        raise ValueError(msg)
+    needed = _collect_needed_backends(config)
+    _validate_needed_backends(needed)
As per coding guidelines, "Keep functions under 50 lines and files under 800 lines".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/tools/sandbox/factory.py` around lines 38 - 111, The
build_sandbox_backends function exceeds the 50-line limit; refactor by
extracting validation and instantiation into small helpers: create a
validate_backends(needed: set[str]) function that checks needed against
_KNOWN_BACKENDS and logs/raises using SANDBOX_FACTORY_BUILD_FAILED, and an
instantiate_backend(name: str, config, workspace: Path) -> SandboxBackend helper
that constructs SubprocessSandbox or DockerSandbox, logs errors with
SANDBOX_FACTORY_BUILD_FAILED and re-raises; then have build_sandbox_backends
compute needed, call validate_backends(needed), loop needed to call
instantiate_backend for each required backend, collect into backends, and
finally log SANDBOX_FACTORY_BUILT and return MappingProxyType(backends).



def resolve_sandbox_for_category(
*,
config: SandboxingConfig,
backends: Mapping[str, SandboxBackend],
category: ToolCategory,
) -> SandboxBackend:
"""Look up the correct backend for a tool category.

Uses ``config.backend_for_category()`` to determine the backend
name, then returns the corresponding instance from *backends*.

Args:
config: Top-level sandboxing configuration.
backends: Mapping of backend name to backend instance.
category: The tool category to resolve.

Returns:
The ``SandboxBackend`` instance for the given category.

Raises:
KeyError: If the resolved backend name is not present in
*backends*.
"""
backend_name = config.backend_for_category(category.value)
try:
backend = backends[backend_name]
except KeyError:
msg = (
f"Backend {backend_name!r} resolved for category "
f"{category.value!r} not found in backends mapping "
f"(available: {sorted(backends.keys())})"
)
logger.warning(
SANDBOX_FACTORY_RESOLVE,
category=category.value,
backend=backend_name,
error=msg,
)
raise KeyError(msg) from None

logger.debug(
SANDBOX_FACTORY_RESOLVE,
category=category.value,
backend=backend_name,
)
return backend


async def cleanup_sandbox_backends(
backends: Mapping[str, SandboxBackend],
) -> None:
"""Clean up all backends by calling ``cleanup()`` on each.

Errors from individual backends are logged but do not prevent
cleanup of remaining backends. Uses ``asyncio.TaskGroup`` for
parallel cleanup.

Args:
backends: Mapping of backend name to backend instance.
"""

async def _cleanup_one(name: str, backend: SandboxBackend) -> None:
logger.debug(SANDBOX_FACTORY_CLEANUP, backend=name)
try:
await backend.cleanup()
except Exception:
logger.warning(
SANDBOX_FACTORY_CLEANUP,
backend=name,
error=f"cleanup failed for backend {name!r}",
exc_info=True,
)

async with asyncio.TaskGroup() as tg:
for name, backend in backends.items():
tg.create_task(_cleanup_one(name, backend))
Loading
Loading