diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6a90349a0..b8a2745da 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -161,6 +161,7 @@ class AgentLoop: unified_session: bool = False, disabled_skills: list[str] | None = None, self_evolution: bool = False, + self_modify: bool = True, ): from nanobot.config.schema import ExecToolConfig, WebToolsConfig @@ -248,7 +249,7 @@ class AgentLoop: ) self._register_default_tools() if self_evolution: - self.tools.register(SelfTool(loop=self)) + self.tools.register(SelfTool(loop=self, modify_allowed=self_modify)) self._config_defaults: dict[str, Any] = { "max_iterations": self.max_iterations, "context_window_tokens": self.context_window_tokens, diff --git a/nanobot/agent/tools/self.py b/nanobot/agent/tools/self.py index 33ac097bc..c83e52ab9 100644 --- a/nanobot/agent/tools/self.py +++ b/nanobot/agent/tools/self.py @@ -60,8 +60,9 @@ class SelfTool(Tool): _MAX_RUNTIME_KEYS = 64 _CALL_TIMEOUT_SECONDS = 30 - def __init__(self, loop: AgentLoop) -> None: + def __init__(self, loop: AgentLoop, modify_allowed: bool = True) -> None: self._loop = loop + self._modify_allowed = modify_allowed self._channel = "" self._chat_id = "" @@ -70,6 +71,7 @@ class SelfTool(Tool): result = cls.__new__(cls) memo[id(self)] = result result._loop = self._loop + result._modify_allowed = self._modify_allowed result._channel = self._channel result._chat_id = self._chat_id return result @@ -84,18 +86,24 @@ class SelfTool(Tool): @property def description(self) -> str: - return ( + base = ( "Inspect and modify your own runtime state, navigate related objects, " "invoke methods, and save/restore configuration snapshots. " "Use 'inspect' with dot-path to explore (e.g. 'subagents._running_tasks'), " "'modify' to change values, 'call' to invoke methods, " "'list_tools' to see registered tools, 'manage_tool' to register/unregister, " - "'snapshot'/'restore' to save/load config templates.\n" - "IMPORTANT: Before modifying state or invoking methods, predict the potential impact. " - "If the operation could cause crashes, data loss, or instability " - "(e.g. replacing tools, changing model, resetting critical config), " - "warn the user about the risk BEFORE executing and ask for confirmation." + "'snapshot'/'restore' to save/load config templates." ) + if not self._modify_allowed: + base += "\nREAD-ONLY MODE: modify, call, manage_tool, snapshot, restore, and reset are disabled." + else: + base += ( + "\nIMPORTANT: Before modifying state or invoking methods, predict the potential impact. " + "If the operation could cause crashes, data loss, or instability " + "(e.g. replacing tools, changing model, resetting critical config), " + "warn the user about the risk BEFORE executing and ask for confirmation." + ) + return base @property def parameters(self) -> dict[str, Any]: @@ -297,20 +305,23 @@ class SelfTool(Tool): ) -> str: if action == "inspect": return self._inspect(key) + if action == "list_tools": + return self._list_tools() + if action == "list_snapshots": + return self._list_snapshots() + # Write operations — require modify_allowed + if not self._modify_allowed and action in ("modify", "call", "manage_tool", "reset", "snapshot", "restore"): + return "Error: modify actions are disabled (self_modify is False)" if action == "modify": return self._modify(key, value) if action == "call": return await self._call(method, args) - if action == "list_tools": - return self._list_tools() if action == "manage_tool": return self._manage_tool(manage_action, name) if action == "snapshot": return self._snapshot(name) if action == "restore": return self._restore(name) - if action == "list_snapshots": - return self._list_snapshots() if action == "reset": return self._reset(key) # Backward compat aliases diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0f9d451cb..f7337e2df 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -594,6 +594,7 @@ def serve( disabled_skills=runtime_config.agents.defaults.disabled_skills, session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes, self_evolution=runtime_config.tools.self_evolution, + self_modify=runtime_config.tools.self_modify, ) model_name = runtime_config.agents.defaults.model @@ -689,6 +690,7 @@ def gateway( disabled_skills=config.agents.defaults.disabled_skills, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, self_evolution=config.tools.self_evolution, + self_modify=config.tools.self_modify, ) # Set cron callback (needs agent) @@ -924,6 +926,7 @@ def agent( disabled_skills=config.agents.defaults.disabled_skills, session_ttl_minutes=config.agents.defaults.session_ttl_minutes, self_evolution=config.tools.self_evolution, + self_modify=config.tools.self_modify, ) restart_notice = consume_restart_notice_from_env() if restart_notice and should_show_cli_restart_notice(restart_notice, session_id): diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 45579d24c..a928aa20c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -207,6 +207,7 @@ class ToolsConfig(Base): mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict) ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale) self_evolution: bool = False # enable the self tool (agent can inspect/modify its own runtime state) + self_modify: bool = True # allow self tool to modify state/call methods (read-only if False) class Config(BaseSettings): diff --git a/nanobot/nanobot.py b/nanobot/nanobot.py index 0b25f7f80..0521fb809 100644 --- a/nanobot/nanobot.py +++ b/nanobot/nanobot.py @@ -85,6 +85,7 @@ class Nanobot: disabled_skills=defaults.disabled_skills, session_ttl_minutes=defaults.session_ttl_minutes, self_evolution=config.tools.self_evolution, + self_modify=config.tools.self_modify, ) return cls(loop) diff --git a/tests/agent/tools/test_self_tool.py b/tests/agent/tools/test_self_tool.py index 030ca3c99..fa619142d 100644 --- a/tests/agent/tools/test_self_tool.py +++ b/tests/agent/tools/test_self_tool.py @@ -1056,3 +1056,77 @@ class TestInspectTaskStatuses: assert "xyz" in result assert "search code" in result assert "stop_reason: completed" in result + + +# --------------------------------------------------------------------------- +# read-only mode (self_modify=False) +# --------------------------------------------------------------------------- + +class TestReadOnlyMode: + + def _make_readonly_tool(self): + loop = _make_mock_loop() + return SelfTool(loop=loop, modify_allowed=False) + + @pytest.mark.asyncio + async def test_inspect_allowed_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="inspect", key="max_iterations") + assert "40" in result + + @pytest.mark.asyncio + async def test_list_tools_allowed_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="list_tools") + assert "read_file" in result + + @pytest.mark.asyncio + async def test_list_snapshots_allowed_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="list_snapshots") + assert "No snapshots" in result + + @pytest.mark.asyncio + async def test_modify_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="modify", key="max_iterations", value=80) + assert "disabled" in result + + @pytest.mark.asyncio + async def test_call_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="call", method="subagents.get_running_count") + assert "disabled" in result + + @pytest.mark.asyncio + async def test_manage_tool_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="manage_tool", name="web_search", manage_action="unregister") + assert "disabled" in result + + @pytest.mark.asyncio + async def test_snapshot_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="snapshot", name="test") + assert "disabled" in result + + @pytest.mark.asyncio + async def test_restore_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="restore", name="test") + assert "disabled" in result + + @pytest.mark.asyncio + async def test_reset_blocked_in_readonly(self): + tool = self._make_readonly_tool() + result = await tool.execute(action="reset", key="max_iterations") + assert "disabled" in result + + def test_description_shows_readonly(self): + tool = self._make_readonly_tool() + assert "READ-ONLY MODE" in tool.description + + def test_description_shows_warning_when_modify_allowed(self): + tool = _make_tool() + assert "IMPORTANT" in tool.description + assert "READ-ONLY" not in tool.description