diff --git a/README.md b/README.md index 8ac23a0..9f23e15 100644 --- a/README.md +++ b/README.md @@ -1163,6 +1163,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | +| `tools.exec.enable` | `true` | When `false`, the shell `exec` tool is not registered at all. Use this to completely disable shell command execution. | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. | diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e8e2064..be820ef 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -120,12 +120,13 @@ class AgentLoop: self.tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read)) for cls in (WriteFileTool, EditFileTool, ListDirTool): self.tools.register(cls(workspace=self.workspace, allowed_dir=allowed_dir)) - self.tools.register(ExecTool( - working_dir=str(self.workspace), - timeout=self.exec_config.timeout, - restrict_to_workspace=self.restrict_to_workspace, - path_append=self.exec_config.path_append, - )) + if self.exec_config.enable: + self.tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.restrict_to_workspace, + path_append=self.exec_config.path_append, + )) self.tools.register(WebSearchTool(config=self.web_search_config, proxy=self.web_proxy)) self.tools.register(WebFetchTool(proxy=self.web_proxy)) self.tools.register(MessageTool(send_callback=self.bus.publish_outbound)) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index aa7e80d..78cba1d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -118,10 +118,10 @@ class WebToolsConfig(Base): class ExecToolConfig(Base): """Shell exec tool configuration.""" + enable: bool = True timeout: int = 60 path_append: str = "" - class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" diff --git a/tests/test_task_cancel.py b/tests/test_task_cancel.py index 62ab2cc..5bc2ea9 100644 --- a/tests/test_task_cancel.py +++ b/tests/test_task_cancel.py @@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -def _make_loop(): +def _make_loop(*, exec_config=None): """Create a minimal AgentLoop with mocked dependencies.""" from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus @@ -23,7 +23,7 @@ def _make_loop(): patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) - loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace, exec_config=exec_config) return loop, bus @@ -90,6 +90,13 @@ class TestHandleStop: class TestDispatch: + def test_exec_tool_not_registered_when_disabled(self): + from nanobot.config.schema import ExecToolConfig + + loop, _bus = _make_loop(exec_config=ExecToolConfig(enable=False)) + + assert loop.tools.get("exec") is None + @pytest.mark.asyncio async def test_dispatch_processes_and_publishes(self): from nanobot.bus.events import InboundMessage, OutboundMessage