Replace load_skill tool with read_file extra_allowed_dirs for builtin skills access
Instead of adding a separate load_skill tool to bypass workspace restrictions, extend ReadFileTool with extra_allowed_dirs so it can read builtin skill paths while keeping write/edit tools locked to the workspace. Fixes the original issue for both main agent and subagents. Made-with: Cursor
This commit is contained in:
@@ -46,7 +46,7 @@ class ContextBuilder:
|
|||||||
if skills_summary:
|
if skills_summary:
|
||||||
parts.append(f"""# Skills
|
parts.append(f"""# Skills
|
||||||
|
|
||||||
The following skills extend your capabilities. To use a skill, call the load_skill tool with its name.
|
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
||||||
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
||||||
|
|
||||||
{skills_summary}""")
|
{skills_summary}""")
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ from nanobot.agent.context import ContextBuilder
|
|||||||
from nanobot.agent.memory import MemoryConsolidator
|
from nanobot.agent.memory import MemoryConsolidator
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
from nanobot.agent.tools.cron import CronTool
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, LoadSkillTool, ReadFileTool, WriteFileTool
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
@@ -114,7 +115,9 @@ class AgentLoop:
|
|||||||
def _register_default_tools(self) -> None:
|
def _register_default_tools(self) -> None:
|
||||||
"""Register the default set of tools."""
|
"""Register the default set of tools."""
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||||
for cls in (ReadFileTool, WriteFileTool, EditFileTool, ListDirTool):
|
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||||
|
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(cls(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
self.tools.register(ExecTool(
|
self.tools.register(ExecTool(
|
||||||
working_dir=str(self.workspace),
|
working_dir=str(self.workspace),
|
||||||
@@ -128,7 +131,6 @@ class AgentLoop:
|
|||||||
self.tools.register(SpawnTool(manager=self.subagents))
|
self.tools.register(SpawnTool(manager=self.subagents))
|
||||||
if self.cron_service:
|
if self.cron_service:
|
||||||
self.tools.register(CronTool(self.cron_service))
|
self.tools.register(CronTool(self.cron_service))
|
||||||
self.tools.register(LoadSkillTool(skills_loader=self.context.skills))
|
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
"""Connect to configured MCP servers (one-time, lazy)."""
|
"""Connect to configured MCP servers (one-time, lazy)."""
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ class SkillsLoader:
|
|||||||
lines = ["<skills>"]
|
lines = ["<skills>"]
|
||||||
for s in all_skills:
|
for s in all_skills:
|
||||||
name = escape_xml(s["name"])
|
name = escape_xml(s["name"])
|
||||||
|
path = s["path"]
|
||||||
desc = escape_xml(self._get_skill_description(s["name"]))
|
desc = escape_xml(self._get_skill_description(s["name"]))
|
||||||
skill_meta = self._get_skill_meta(s["name"])
|
skill_meta = self._get_skill_meta(s["name"])
|
||||||
available = self._check_requirements(skill_meta)
|
available = self._check_requirements(skill_meta)
|
||||||
@@ -125,6 +126,7 @@ class SkillsLoader:
|
|||||||
lines.append(f" <skill available=\"{str(available).lower()}\">")
|
lines.append(f" <skill available=\"{str(available).lower()}\">")
|
||||||
lines.append(f" <name>{name}</name>")
|
lines.append(f" <name>{name}</name>")
|
||||||
lines.append(f" <description>{desc}</description>")
|
lines.append(f" <description>{desc}</description>")
|
||||||
|
lines.append(f" <location>{path}</location>")
|
||||||
|
|
||||||
# Show missing requirements for unavailable skills
|
# Show missing requirements for unavailable skills
|
||||||
if not available:
|
if not available:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.shell import ExecTool
|
from nanobot.agent.tools.shell import ExecTool
|
||||||
@@ -92,7 +93,8 @@ class SubagentManager:
|
|||||||
# Build subagent tools (no message tool, no spawn tool)
|
# Build subagent tools (no message tool, no spawn tool)
|
||||||
tools = ToolRegistry()
|
tools = ToolRegistry()
|
||||||
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
allowed_dir = self.workspace if self.restrict_to_workspace else None
|
||||||
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
extra_read = [BUILTIN_SKILLS_DIR] if allowed_dir else None
|
||||||
|
tools.register(ReadFileTool(workspace=self.workspace, allowed_dir=allowed_dir, extra_allowed_dirs=extra_read))
|
||||||
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
tools.register(WriteFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
tools.register(EditFileTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
tools.register(ListDirTool(workspace=self.workspace, allowed_dir=allowed_dir))
|
||||||
@@ -213,7 +215,7 @@ Stay focused on the assigned task. Your final response will be reported back to
|
|||||||
|
|
||||||
skills_summary = SkillsLoader(self.workspace).build_skills_summary()
|
skills_summary = SkillsLoader(self.workspace).build_skills_summary()
|
||||||
if skills_summary:
|
if skills_summary:
|
||||||
parts.append(f"## Skills\n\nUse load_skill tool to load a skill by name.\n\n{skills_summary}")
|
parts.append(f"## Skills\n\nRead SKILL.md with read_file to use a skill.\n\n{skills_summary}")
|
||||||
|
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ from nanobot.agent.tools.base import Tool
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_path(
|
def _resolve_path(
|
||||||
path: str, workspace: Path | None = None, allowed_dir: Path | None = None
|
path: str,
|
||||||
|
workspace: Path | None = None,
|
||||||
|
allowed_dir: Path | None = None,
|
||||||
|
extra_allowed_dirs: list[Path] | None = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""Resolve path against workspace (if relative) and enforce directory restriction."""
|
"""Resolve path against workspace (if relative) and enforce directory restriction."""
|
||||||
p = Path(path).expanduser()
|
p = Path(path).expanduser()
|
||||||
@@ -16,22 +19,35 @@ def _resolve_path(
|
|||||||
p = workspace / p
|
p = workspace / p
|
||||||
resolved = p.resolve()
|
resolved = p.resolve()
|
||||||
if allowed_dir:
|
if allowed_dir:
|
||||||
try:
|
all_dirs = [allowed_dir] + (extra_allowed_dirs or [])
|
||||||
resolved.relative_to(allowed_dir.resolve())
|
if not any(_is_under(resolved, d) for d in all_dirs):
|
||||||
except ValueError:
|
|
||||||
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
raise PermissionError(f"Path {path} is outside allowed directory {allowed_dir}")
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _is_under(path: Path, directory: Path) -> bool:
|
||||||
|
try:
|
||||||
|
path.relative_to(directory.resolve())
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class _FsTool(Tool):
|
class _FsTool(Tool):
|
||||||
"""Shared base for filesystem tools — common init and path resolution."""
|
"""Shared base for filesystem tools — common init and path resolution."""
|
||||||
|
|
||||||
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
workspace: Path | None = None,
|
||||||
|
allowed_dir: Path | None = None,
|
||||||
|
extra_allowed_dirs: list[Path] | None = None,
|
||||||
|
):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
self._allowed_dir = allowed_dir
|
||||||
|
self._extra_allowed_dirs = extra_allowed_dirs
|
||||||
|
|
||||||
def _resolve(self, path: str) -> Path:
|
def _resolve(self, path: str) -> Path:
|
||||||
return _resolve_path(path, self._workspace, self._allowed_dir)
|
return _resolve_path(path, self._workspace, self._allowed_dir, self._extra_allowed_dirs)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -363,35 +379,3 @@ class ListDirTool(_FsTool):
|
|||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error listing directory: {e}"
|
return f"Error listing directory: {e}"
|
||||||
|
|
||||||
|
|
||||||
class LoadSkillTool(Tool):
|
|
||||||
"""Tool to load a skill by name, bypassing workspace restriction."""
|
|
||||||
|
|
||||||
def __init__(self, skills_loader):
|
|
||||||
self._skills_loader = skills_loader
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "load_skill"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "Load a skill by name. Returns the full SKILL.md content."
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {"name": {"type": "string", "description": "The skill name to load"}},
|
|
||||||
"required": ["name"],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def execute(self, name: str, **kwargs: Any) -> str:
|
|
||||||
try:
|
|
||||||
content = self._skills_loader.load_skill(name)
|
|
||||||
if content is None:
|
|
||||||
return f"Error: Skill not found: {name}"
|
|
||||||
return content
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error loading skill: {str(e)}"
|
|
||||||
|
|||||||
@@ -251,3 +251,114 @@ class TestListDirTool:
|
|||||||
result = await tool.execute(path=str(tmp_path / "nope"))
|
result = await tool.execute(path=str(tmp_path / "nope"))
|
||||||
assert "Error" in result
|
assert "Error" in result
|
||||||
assert "not found" in result
|
assert "not found" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Workspace restriction + extra_allowed_dirs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestWorkspaceRestriction:
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_blocked_outside_workspace(self, tmp_path):
|
||||||
|
workspace = tmp_path / "ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
secret = outside / "secret.txt"
|
||||||
|
secret.write_text("top secret")
|
||||||
|
|
||||||
|
tool = ReadFileTool(workspace=workspace, allowed_dir=workspace)
|
||||||
|
result = await tool.execute(path=str(secret))
|
||||||
|
assert "Error" in result
|
||||||
|
assert "outside" in result.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_allowed_with_extra_dir(self, tmp_path):
|
||||||
|
workspace = tmp_path / "ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
skill_file = skills_dir / "test_skill" / "SKILL.md"
|
||||||
|
skill_file.parent.mkdir()
|
||||||
|
skill_file.write_text("# Test Skill\nDo something.")
|
||||||
|
|
||||||
|
tool = ReadFileTool(
|
||||||
|
workspace=workspace, allowed_dir=workspace,
|
||||||
|
extra_allowed_dirs=[skills_dir],
|
||||||
|
)
|
||||||
|
result = await tool.execute(path=str(skill_file))
|
||||||
|
assert "Test Skill" in result
|
||||||
|
assert "Error" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extra_dirs_does_not_widen_write(self, tmp_path):
|
||||||
|
from nanobot.agent.tools.filesystem import WriteFileTool
|
||||||
|
|
||||||
|
workspace = tmp_path / "ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
outside.mkdir()
|
||||||
|
|
||||||
|
tool = WriteFileTool(workspace=workspace, allowed_dir=workspace)
|
||||||
|
result = await tool.execute(path=str(outside / "hack.txt"), content="pwned")
|
||||||
|
assert "Error" in result
|
||||||
|
assert "outside" in result.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_still_blocked_for_unrelated_dir(self, tmp_path):
|
||||||
|
workspace = tmp_path / "ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
unrelated = tmp_path / "other"
|
||||||
|
unrelated.mkdir()
|
||||||
|
secret = unrelated / "secret.txt"
|
||||||
|
secret.write_text("nope")
|
||||||
|
|
||||||
|
tool = ReadFileTool(
|
||||||
|
workspace=workspace, allowed_dir=workspace,
|
||||||
|
extra_allowed_dirs=[skills_dir],
|
||||||
|
)
|
||||||
|
result = await tool.execute(path=str(secret))
|
||||||
|
assert "Error" in result
|
||||||
|
assert "outside" in result.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_file_still_readable_with_extra_dirs(self, tmp_path):
|
||||||
|
"""Adding extra_allowed_dirs must not break normal workspace reads."""
|
||||||
|
workspace = tmp_path / "ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
ws_file = workspace / "README.md"
|
||||||
|
ws_file.write_text("hello from workspace")
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
|
||||||
|
tool = ReadFileTool(
|
||||||
|
workspace=workspace, allowed_dir=workspace,
|
||||||
|
extra_allowed_dirs=[skills_dir],
|
||||||
|
)
|
||||||
|
result = await tool.execute(path=str(ws_file))
|
||||||
|
assert "hello from workspace" in result
|
||||||
|
assert "Error" not in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_edit_blocked_in_extra_dir(self, tmp_path):
|
||||||
|
"""edit_file must not be able to modify files in extra_allowed_dirs."""
|
||||||
|
workspace = tmp_path / "ws"
|
||||||
|
workspace.mkdir()
|
||||||
|
skills_dir = tmp_path / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
skill_file = skills_dir / "weather" / "SKILL.md"
|
||||||
|
skill_file.parent.mkdir()
|
||||||
|
skill_file.write_text("# Weather\nOriginal content.")
|
||||||
|
|
||||||
|
tool = EditFileTool(workspace=workspace, allowed_dir=workspace)
|
||||||
|
result = await tool.execute(
|
||||||
|
path=str(skill_file),
|
||||||
|
old_text="Original content.",
|
||||||
|
new_text="Hacked content.",
|
||||||
|
)
|
||||||
|
assert "Error" in result
|
||||||
|
assert "outside" in result.lower()
|
||||||
|
assert skill_file.read_text() == "# Weather\nOriginal content."
|
||||||
|
|||||||
Reference in New Issue
Block a user