diff --git a/CLAUDE.md b/CLAUDE.md index e614fa64b4..16dfafdf08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,7 +132,7 @@ src/synthorg/ definitions/ # Per-namespace setting definitions (api, company, providers, memory, budget, security, coordination, observability, backup) 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) + 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) web/ # Vue 3 + PrimeVue + Tailwind CSS dashboard src/ @@ -196,7 +196,7 @@ site/ # Astro landing page (synthorg.io) - **Every module** with business logic MUST have: `from synthorg.observability import get_logger` then `logger = get_logger(__name__)` - **Never** use `import logging` / `logging.getLogger()` / `print()` in application code - **Variable name**: always `logger` (not `_logger`, not `log`) -- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `PROVIDER_CALL_START` from `events.provider`, `BUDGET_RECORD_ADDED` from `events.budget`, `CFO_ANOMALY_DETECTED` from `events.cfo`, `CONFLICT_DETECTED` from `events.conflict`, `MEETING_STARTED` from `events.meeting`, `MEETING_SCHEDULER_STARTED` from `events.meeting`, `MEETING_SCHEDULER_ERROR` from `events.meeting`, `MEETING_SCHEDULER_STOPPED` from `events.meeting`, `MEETING_PERIODIC_TRIGGERED` from `events.meeting`, `MEETING_EVENT_TRIGGERED` from `events.meeting`, `MEETING_PARTICIPANTS_RESOLVED` from `events.meeting`, `MEETING_NO_PARTICIPANTS` from `events.meeting`, `MEETING_NOT_FOUND` from `events.meeting`, `CLASSIFICATION_START` from `events.classification`, `CONSOLIDATION_START` from `events.consolidation`, `ORG_MEMORY_QUERY_START` from `events.org_memory`, `API_REQUEST_STARTED` from `events.api`, `API_REQUEST_COMPLETED` from `events.api`, `API_REQUEST_ERROR` from `events.api`, `API_ROUTE_NOT_FOUND` from `events.api`, `API_HEALTH_CHECK` from `events.api`, `API_COORDINATION_STARTED` from `events.api`, `API_COORDINATION_COMPLETED` from `events.api`, `API_COORDINATION_FAILED` from `events.api`, `API_COORDINATION_AGENT_RESOLVE_FAILED` from `events.api`, `API_CONTENT_NEGOTIATED` from `events.api`, `API_CORRELATION_FALLBACK` from `events.api`, `API_ACCEPT_PARSE_FAILED` from `events.api`, `API_WS_TICKET_ISSUED` from `events.api`, `API_WS_TICKET_CONSUMED` from `events.api`, `API_WS_TICKET_EXPIRED` from `events.api`, `API_WS_TICKET_INVALID` from `events.api`, `API_WS_TICKET_CLEANUP` from `events.api`, `CODE_RUNNER_EXECUTE_START` from `events.code_runner`, `DOCKER_EXECUTE_START` from `events.docker`, `MCP_INVOKE_START` from `events.mcp`, `SECURITY_EVALUATE_START` from `events.security`, `HR_HIRING_REQUEST_CREATED` from `events.hr`, `PERF_METRIC_RECORDED` from `events.performance`, `PERF_LLM_SAMPLE_STARTED` from `events.performance`, `PERF_LLM_SAMPLE_COMPLETED` from `events.performance`, `PERF_LLM_SAMPLE_FAILED` from `events.performance`, `PERF_OVERRIDE_SET` from `events.performance`, `PERF_OVERRIDE_CLEARED` from `events.performance`, `PERF_OVERRIDE_APPLIED` from `events.performance`, `PERF_OVERRIDE_EXPIRED` from `events.performance`, `TRUST_EVALUATE_START` from `events.trust`, `PROMOTION_EVALUATE_START` from `events.promotion`, `PROMPT_BUILD_START` from `events.prompt`, `MEMORY_RETRIEVAL_START` from `events.memory`, `MEMORY_BACKEND_CONNECTED` from `events.memory`, `MEMORY_ENTRY_STORED` from `events.memory`, `MEMORY_BACKEND_SYSTEM_ERROR` from `events.memory`, `MEMORY_RRF_FUSION_COMPLETE` from `events.memory`, `MEMORY_RRF_VALIDATION_FAILED` from `events.memory`, `AUTONOMY_ACTION_AUTO_APPROVED` from `events.autonomy`, `TIMEOUT_POLICY_EVALUATED` from `events.timeout`, `PERSISTENCE_AUDIT_ENTRY_SAVED` from `events.persistence`, `TASK_ENGINE_STARTED` from `events.task_engine`, `COORDINATION_STARTED` from `events.coordination`, `COORDINATION_FACTORY_BUILT` from `events.coordination`, `COMMUNICATION_DISPATCH_START` from `events.communication`, `COMPANY_STARTED` from `events.company`, `CONFIG_LOADED` from `events.config`, `CORRELATION_ID_CREATED` from `events.correlation`, `DECOMPOSITION_STARTED` from `events.decomposition`, `DELEGATION_STARTED` from `events.delegation`, `EXECUTION_LOOP_START` from `events.execution`, `CHECKPOINT_SAVED` from `events.checkpoint`, `PERSISTENCE_CHECKPOINT_SAVED` from `events.persistence`, `GIT_COMMAND_START` from `events.git`, `GIT_CLONE_URL_REJECTED` from `events.git`, `GIT_CLONE_SSRF_BLOCKED` from `events.git`, `GIT_CLONE_DNS_FAILED` from `events.git`, `GIT_CLONE_SSRF_DISABLED` from `events.git`, `PARALLEL_GROUP_START` from `events.parallel`, `PERSONALITY_LOADED` from `events.personality`, `QUOTA_CHECKED` from `events.quota`, `ROLE_ASSIGNED` from `events.role`, `ROUTING_STARTED` from `events.routing`, `SANDBOX_EXECUTE_START` from `events.sandbox`, `TASK_CREATED` from `events.task`, `TASK_ASSIGNMENT_STARTED` from `events.task_assignment`, `TASK_ROUTING_STARTED` from `events.task_routing`, `TEMPLATE_LOADED` from `events.template`, `TOOL_INVOKE_START` from `events.tool`, `TOOL_OUTPUT_WITHHELD` from `events.tool`, `WORKSPACE_CREATED` from `events.workspace`, `APPROVAL_GATE_ESCALATION_DETECTED` from `events.approval_gate`, `APPROVAL_GATE_ESCALATION_FAILED` from `events.approval_gate`, `APPROVAL_GATE_INITIALIZED` from `events.approval_gate`, `APPROVAL_GATE_RISK_CLASSIFIED` from `events.approval_gate`, `APPROVAL_GATE_RISK_CLASSIFY_FAILED` from `events.approval_gate`, `APPROVAL_GATE_CONTEXT_PARKED` from `events.approval_gate`, `APPROVAL_GATE_CONTEXT_PARK_FAILED` from `events.approval_gate`, `APPROVAL_GATE_PARK_TASKLESS` from `events.approval_gate`, `APPROVAL_GATE_RESUME_STARTED` from `events.approval_gate`, `APPROVAL_GATE_CONTEXT_RESUMED` from `events.approval_gate`, `APPROVAL_GATE_RESUME_FAILED` from `events.approval_gate`, `APPROVAL_GATE_RESUME_DELETE_FAILED` from `events.approval_gate`, `APPROVAL_GATE_RESUME_TRIGGERED` from `events.approval_gate`, `APPROVAL_GATE_NO_PARKED_CONTEXT` from `events.approval_gate`, `APPROVAL_GATE_LOOP_WIRING_WARNING` from `events.approval_gate`, `STAGNATION_CHECK_PERFORMED` from `events.stagnation`, `STAGNATION_DETECTED` from `events.stagnation`, `STAGNATION_CORRECTION_INJECTED` from `events.stagnation`, `STAGNATION_TERMINATED` from `events.stagnation`, `PERSISTENCE_AGENT_STATE_SAVED` from `events.persistence`, `PERSISTENCE_AGENT_STATE_FETCHED` from `events.persistence`, `PERSISTENCE_AGENT_STATE_ACTIVE_QUERIED` from `events.persistence`, `PERSISTENCE_AGENT_STATE_DELETED` from `events.persistence`, `SETTINGS_VALUE_SET` from `events.settings`, `SETTINGS_VALUE_DELETED` from `events.settings`, `SETTINGS_VALUE_RESOLVED` from `events.settings`, `SETTINGS_CACHE_INVALIDATED` from `events.settings`, `SETTINGS_ENCRYPTION_ERROR` from `events.settings`, `SETTINGS_VALIDATION_FAILED` from `events.settings`, `SETTINGS_NOTIFICATION_PUBLISHED` from `events.settings`, `SETTINGS_NOTIFICATION_FAILED` from `events.settings`, `SETTINGS_FETCH_FAILED` from `events.settings`, `SETTINGS_SET_FAILED` from `events.settings`, `SETTINGS_DELETE_FAILED` from `events.settings`, `SETTINGS_NOT_FOUND` from `events.settings`, `SETTINGS_REGISTRY_DUPLICATE` from `events.settings`, `SETTINGS_CONFIG_PATH_MISS` from `events.settings`). Import directly: `from synthorg.observability.events. import EVENT_CONSTANT` +- **Event names**: always use constants from the domain-specific module under `synthorg.observability.events` (e.g., `API_REQUEST_STARTED` from `events.api`, `TOOL_INVOKE_START` from `events.tool`, `GIT_COMMAND_START` from `events.git`). Each domain has its own module — see `src/synthorg/observability/events/` for the full inventory of constants. Import directly: `from synthorg.observability.events. import EVENT_CONSTANT` - **Structured kwargs**: always `logger.info(EVENT, key=value)` — never `logger.info("msg %s", val)` - **All error paths** must log at WARNING or ERROR with context before raising - **All state transitions** must log at INFO diff --git a/src/synthorg/observability/events/tool.py b/src/synthorg/observability/events/tool.py index aa980ce30c..64f671b45b 100644 --- a/src/synthorg/observability/events/tool.py +++ b/src/synthorg/observability/events/tool.py @@ -23,6 +23,11 @@ TOOL_PERMISSION_CHECKER_CREATED: Final[str] = "tool.permission.checker_created" TOOL_PERMISSION_FILTERED: Final[str] = "tool.permission.filtered" +# ── Factory events ────────────────────────────────────────────── +TOOL_FACTORY_BUILT: Final[str] = "tool.factory.built" +TOOL_FACTORY_CONFIG_ENTRY: Final[str] = "tool.factory.config_entry" +TOOL_FACTORY_ERROR: Final[str] = "tool.factory.error" + # ── File system tool events ────────────────────────────────────── TOOL_FS_READ: Final[str] = "tool.fs.read" TOOL_FS_WRITE: Final[str] = "tool.fs.write" diff --git a/src/synthorg/tools/__init__.py b/src/synthorg/tools/__init__.py index 92a617a792..d64315cecb 100644 --- a/src/synthorg/tools/__init__.py +++ b/src/synthorg/tools/__init__.py @@ -11,6 +11,7 @@ ToolPermissionDeniedError, ) from .examples.echo import EchoTool +from .factory import build_default_tools, build_default_tools_from_config from .file_system import ( BaseFileSystemTool, DeleteFileTool, @@ -86,4 +87,6 @@ "ToolPermissionDeniedError", "ToolRegistry", "WriteFileTool", + "build_default_tools", + "build_default_tools_from_config", ] diff --git a/src/synthorg/tools/factory.py b/src/synthorg/tools/factory.py new file mode 100644 index 0000000000..98bb1dcca6 --- /dev/null +++ b/src/synthorg/tools/factory.py @@ -0,0 +1,161 @@ +"""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 +extracts parameters from a ``RootConfig``). Both return +``tuple[BaseTool, ...]`` so callers can extend before wrapping +in a ``ToolRegistry``. +""" + +from typing import TYPE_CHECKING + +from synthorg.observability import get_logger +from synthorg.observability.events.tool import ( + TOOL_FACTORY_BUILT, + TOOL_FACTORY_CONFIG_ENTRY, + TOOL_FACTORY_ERROR, +) +from synthorg.tools.file_system import ( + DeleteFileTool, + EditFileTool, + ListDirectoryTool, + ReadFileTool, + WriteFileTool, +) +from synthorg.tools.git_tools import ( + GitBranchTool, + GitCloneTool, + GitCommitTool, + GitDiffTool, + GitLogTool, + GitStatusTool, +) + +if TYPE_CHECKING: + from pathlib import Path + + from synthorg.config.schema import RootConfig + from synthorg.tools.base import BaseTool + from synthorg.tools.git_url_validator import GitCloneNetworkPolicy + from synthorg.tools.sandbox.protocol import SandboxBackend + +logger = get_logger(__name__) + + +def _build_file_system_tools( + *, + workspace: Path, +) -> tuple[BaseTool, ...]: + """Instantiate the five built-in file-system tools.""" + return ( + ReadFileTool(workspace_root=workspace), + WriteFileTool(workspace_root=workspace), + EditFileTool(workspace_root=workspace), + ListDirectoryTool(workspace_root=workspace), + DeleteFileTool(workspace_root=workspace), + ) + + +def _build_git_tools( + *, + workspace: Path, + git_clone_policy: GitCloneNetworkPolicy | None, + sandbox: SandboxBackend | None, +) -> tuple[BaseTool, ...]: + """Instantiate the six built-in git tools.""" + return ( + GitStatusTool(workspace=workspace, sandbox=sandbox), + GitLogTool(workspace=workspace, sandbox=sandbox), + GitDiffTool(workspace=workspace, sandbox=sandbox), + GitBranchTool(workspace=workspace, sandbox=sandbox), + GitCommitTool(workspace=workspace, sandbox=sandbox), + GitCloneTool( + workspace=workspace, + sandbox=sandbox, + network_policy=git_clone_policy, + ), + ) + + +def build_default_tools( + *, + workspace: Path, + git_clone_policy: GitCloneNetworkPolicy | None = None, + sandbox: SandboxBackend | None = None, +) -> tuple[BaseTool, ...]: + """Instantiate all built-in workspace tools. + + Args: + workspace: Absolute path to the agent workspace root. + git_clone_policy: Network policy for git clone SSRF + prevention. ``None`` uses the default (block all + private IPs, empty hostname allowlist). + sandbox: Optional sandbox backend for subprocess + isolation (passed to git tools). + + Returns: + Sorted tuple of ``BaseTool`` instances. + + Raises: + ValueError: If *workspace* is not an absolute path. + """ + if not workspace.is_absolute(): + msg = f"workspace must be an absolute path, got: {workspace}" + logger.warning(TOOL_FACTORY_ERROR, error=msg) + raise ValueError(msg) + + all_tools = ( + *_build_file_system_tools(workspace=workspace), + *_build_git_tools( + workspace=workspace, + git_clone_policy=git_clone_policy, + sandbox=sandbox, + ), + ) + result = tuple(sorted(all_tools, key=lambda t: t.name)) + + policy = git_clone_policy + block_ips = policy.block_private_ips if policy is not None else True + allowlist_len = len(policy.hostname_allowlist) if policy is not None else 0 + logger.info( + TOOL_FACTORY_BUILT, + tool_count=len(result), + tools=tuple(t.name for t in result), + git_clone_block_private_ips=block_ips, + git_clone_allowlist_size=allowlist_len, + ) + return result + + +def build_default_tools_from_config( + *, + workspace: Path, + config: RootConfig, + sandbox: 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`. + + Args: + workspace: Absolute path to the agent workspace root. + config: Validated root configuration. + sandbox: Optional sandbox backend for subprocess + isolation (passed to git tools). + + Returns: + Sorted tuple of ``BaseTool`` instances. + + Raises: + ValueError: If *workspace* is not an absolute path. + """ + logger.debug( + TOOL_FACTORY_CONFIG_ENTRY, + source="config", + ) + return build_default_tools( + workspace=workspace, + git_clone_policy=config.git_clone, + sandbox=sandbox, + ) diff --git a/tests/integration/tools/test_factory_integration.py b/tests/integration/tools/test_factory_integration.py new file mode 100644 index 0000000000..57bb132737 --- /dev/null +++ b/tests/integration/tools/test_factory_integration.py @@ -0,0 +1,105 @@ +"""Integration tests for tool factory + config loading pipeline.""" + +from pathlib import Path + +import pytest + +from synthorg.config.loader import load_config_from_string +from synthorg.tools.factory import ( + build_default_tools, + build_default_tools_from_config, +) +from synthorg.tools.git_tools import GitCloneTool +from synthorg.tools.registry import ToolRegistry + +_EXPECTED_TOOL_COUNT: int = 11 + + +@pytest.mark.integration +class TestToolFactoryConfigIntegration: + """Integration: YAML config -> RootConfig -> factory -> tool instances.""" + + def test_yaml_with_allowlist_wires_to_clone_tool( + self, + tmp_path: Path, + ) -> None: + """YAML hostname_allowlist propagates to GitCloneTool.""" + yaml_str = """\ +company_name: test-corp +git_clone: + hostname_allowlist: + - internal.example.com +""" + config = load_config_from_string(yaml_str) + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.hostname_allowlist == ("internal.example.com",) + + def test_yaml_empty_git_clone_uses_defaults( + self, + tmp_path: Path, + ) -> None: + """Empty git_clone section yields default policy.""" + yaml_str = """\ +company_name: test-corp +git_clone: {} +""" + config = load_config_from_string(yaml_str) + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.hostname_allowlist == () + assert clone._network_policy.block_private_ips is True + + def test_yaml_block_private_ips_false_wires_to_clone_tool( + self, + tmp_path: Path, + ) -> None: + """YAML block_private_ips=false propagates to GitCloneTool.""" + yaml_str = """\ +company_name: test-corp +git_clone: + block_private_ips: false +""" + config = load_config_from_string(yaml_str) + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.block_private_ips is False + + def test_yaml_absent_git_clone_uses_defaults( + self, + tmp_path: Path, + ) -> None: + """YAML without git_clone key uses default policy.""" + yaml_str = """\ +company_name: test-corp +""" + config = load_config_from_string(yaml_str) + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.hostname_allowlist == () + assert clone._network_policy.block_private_ips is True + + def test_factory_tools_form_valid_registry( + self, + tmp_path: Path, + ) -> None: + """Factory output can be wrapped in ToolRegistry without errors.""" + tools = build_default_tools(workspace=tmp_path) + registry = ToolRegistry(tools) + assert len(list(registry.all_tools())) == _EXPECTED_TOOL_COUNT diff --git a/tests/unit/tools/test_factory.py b/tests/unit/tools/test_factory.py new file mode 100644 index 0000000000..6838b46d0e --- /dev/null +++ b/tests/unit/tools/test_factory.py @@ -0,0 +1,222 @@ +"""Unit tests for the tool factory module.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from synthorg.config.schema import RootConfig +from synthorg.tools._git_base import _BaseGitTool +from synthorg.tools.base import BaseTool +from synthorg.tools.factory import ( + build_default_tools, + build_default_tools_from_config, +) +from synthorg.tools.file_system import BaseFileSystemTool +from synthorg.tools.git_tools import GitCloneTool +from synthorg.tools.git_url_validator import GitCloneNetworkPolicy + +_EXPECTED_TOOL_NAMES: tuple[str, ...] = ( + "delete_file", + "edit_file", + "git_branch", + "git_clone", + "git_commit", + "git_diff", + "git_log", + "git_status", + "list_directory", + "read_file", + "write_file", +) + + +@pytest.mark.unit +class TestBuildDefaultTools: + """Tests for build_default_tools().""" + + def test_returns_all_expected_tools( + self, + tmp_path: Path, + ) -> None: + """Factory returns all 11 built-in tools sorted by name.""" + tools = build_default_tools(workspace=tmp_path) + names = tuple(t.name for t in tools) + assert names == _EXPECTED_TOOL_NAMES + + @pytest.mark.parametrize( + ("policy", "expected_allowlist", "expected_block_ips"), + [ + pytest.param( + GitCloneNetworkPolicy( + hostname_allowlist=("internal.example.com",), + ), + ("internal.example.com",), + True, + id="custom-allowlist", + ), + pytest.param( + None, + (), + True, + id="default-when-none", + ), + pytest.param( + GitCloneNetworkPolicy(block_private_ips=False), + (), + False, + id="permissive-policy", + ), + ], + ) + def test_git_clone_policy_wiring( + self, + tmp_path: Path, + policy: GitCloneNetworkPolicy | None, + expected_allowlist: tuple[str, ...], + expected_block_ips: bool, + ) -> None: + """Network policy is correctly wired to clone tool.""" + tools = build_default_tools( + workspace=tmp_path, + git_clone_policy=policy, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.hostname_allowlist == expected_allowlist + assert clone._network_policy.block_private_ips is expected_block_ips + + def test_rejects_relative_workspace(self) -> None: + """Relative workspace path raises ValueError.""" + with pytest.raises(ValueError, match="absolute path"): + build_default_tools(workspace=Path("relative/path")) + + def test_file_system_tools_receive_workspace( + self, + tmp_path: Path, + ) -> None: + """All file system tools have correct workspace_root.""" + tools = build_default_tools(workspace=tmp_path) + fs_names = { + "read_file", + "write_file", + "edit_file", + "list_directory", + "delete_file", + } + for tool in tools: + if tool.name in fs_names: + assert isinstance(tool, BaseFileSystemTool) + assert tool.workspace_root == tmp_path.resolve() + + def test_git_tools_receive_workspace( + self, + tmp_path: Path, + ) -> None: + """All git tools have correct workspace path.""" + tools = build_default_tools(workspace=tmp_path) + git_names = { + "git_status", + "git_log", + "git_diff", + "git_branch", + "git_commit", + "git_clone", + } + for tool in tools: + if tool.name in git_names: + assert isinstance(tool, _BaseGitTool) + assert tool.workspace == tmp_path.resolve() + + def test_sandbox_passed_to_git_tools( + self, + tmp_path: Path, + ) -> None: + """Sandbox backend is forwarded to all git tools.""" + mock_sandbox = MagicMock() + tools = build_default_tools( + workspace=tmp_path, + sandbox=mock_sandbox, + ) + git_names = { + "git_status", + "git_log", + "git_diff", + "git_branch", + "git_commit", + "git_clone", + } + for tool in tools: + if tool.name in git_names: + assert isinstance(tool, _BaseGitTool) + assert tool._sandbox is mock_sandbox + + def test_returns_tuple(self, tmp_path: Path) -> None: + """Factory returns a tuple, not a list or other sequence.""" + tools = build_default_tools(workspace=tmp_path) + assert isinstance(tools, tuple) + + def test_all_tools_are_base_tool_instances( + self, + tmp_path: Path, + ) -> None: + """Every returned tool is a BaseTool subclass instance.""" + tools = build_default_tools(workspace=tmp_path) + for tool in tools: + assert isinstance(tool, BaseTool) + + +@pytest.mark.unit +class TestBuildDefaultToolsFromConfig: + """Tests for build_default_tools_from_config().""" + + def test_extracts_policy_from_config( + self, + tmp_path: Path, + ) -> None: + """Policy from RootConfig.git_clone flows to clone tool.""" + policy = GitCloneNetworkPolicy( + hostname_allowlist=("git.corp.example.com",), + ) + config = RootConfig( + company_name="test-corp", + git_clone=policy, + ) + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.hostname_allowlist == ("git.corp.example.com",) + + def test_default_config_uses_default_policy( + self, + tmp_path: Path, + ) -> None: + """Default RootConfig yields default network policy.""" + config = RootConfig(company_name="test-corp") + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, GitCloneTool) + assert clone._network_policy.hostname_allowlist == () + assert clone._network_policy.block_private_ips is True + + def test_sandbox_passed_through_config_wrapper( + self, + tmp_path: Path, + ) -> None: + """Sandbox arg is forwarded by build_default_tools_from_config.""" + mock_sandbox = MagicMock() + config = RootConfig(company_name="test-corp") + tools = build_default_tools_from_config( + workspace=tmp_path, + config=config, + sandbox=mock_sandbox, + ) + clone = next(t for t in tools if t.name == "git_clone") + assert isinstance(clone, _BaseGitTool) + assert clone._sandbox is mock_sandbox