mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 15:19:16 +03:00
fix(plugins): harden activation boundaries
This commit is contained in:
@@ -2354,6 +2354,8 @@ nanobot discovers [Agent Plugins](https://agent-plugins.org/) under `<workspace>
|
|||||||
Directory presence means installed; activation is explicit in **Apps**. Skills use progressive loading and `$skill-name` invocation, with workspace > plugin > built-in precedence.
|
Directory presence means installed; activation is explicit in **Apps**. Skills use progressive loading and `$skill-name` invocation, with workspace > plugin > built-in precedence.
|
||||||
Enabled `stdio` servers receive contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit
|
Enabled `stdio` servers receive contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit
|
||||||
`tools.mcpServers` entries win collisions. Invalid or escaping components are ignored.
|
`tools.mcpServers` entries win collisions. Invalid or escaping components are ignored.
|
||||||
|
An enabled package is treated as immutable: changing any packaged file disables it until the user
|
||||||
|
reviews and enables it again. Runtime state belongs under `PLUGIN_DATA`, not the package root.
|
||||||
|
|
||||||
Enabled plugins run as the nanobot user; permissions are descriptive, not an OS sandbox. The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
Enabled plugins run as the nanobot user; permissions are descriptive, not an OS sandbox. The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB.
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ def agent_plugin_mcp_servers(
|
|||||||
continue
|
continue
|
||||||
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||||
for name, server in plugin_servers.items():
|
for name, server in plugin_servers.items():
|
||||||
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
|
# ``--`` cannot occur in a valid plugin identity, so multi-server
|
||||||
|
# namespaces cannot collide with a single-server plugin name.
|
||||||
|
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}--{name}"
|
||||||
servers[host_name] = server
|
servers[host_name] = server
|
||||||
configured = configured or {}
|
configured = configured or {}
|
||||||
if collisions := servers.keys() & configured.keys():
|
if collisions := servers.keys() & configured.keys():
|
||||||
@@ -146,7 +148,10 @@ def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None:
|
|||||||
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||||
marker = data / "enabled"
|
marker = data / "enabled"
|
||||||
if enabled:
|
if enabled:
|
||||||
marker.write_text(str(plugin.root), encoding="utf-8")
|
activation = _activation_marker(plugin)
|
||||||
|
if activation is None:
|
||||||
|
raise RuntimeError(f"Agent Plugin '{name}' changed while it was being enabled")
|
||||||
|
marker.write_text(activation, encoding="utf-8")
|
||||||
marker.chmod(0o600)
|
marker.chmod(0o600)
|
||||||
else:
|
else:
|
||||||
marker.unlink(missing_ok=True)
|
marker.unlink(missing_ok=True)
|
||||||
@@ -238,6 +243,7 @@ def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig |
|
|||||||
"args": [_expand(item, root, data) for item in server.args],
|
"args": [_expand(item, root, data) for item in server.args],
|
||||||
"env": {
|
"env": {
|
||||||
**{key: _expand(value, root, data) for key, value in server.env.items()},
|
**{key: _expand(value, root, data) for key, value in server.env.items()},
|
||||||
|
"PYTHONDONTWRITEBYTECODE": "1",
|
||||||
"PLUGIN_ROOT": str(root),
|
"PLUGIN_ROOT": str(root),
|
||||||
"PLUGIN_DATA": str(data),
|
"PLUGIN_DATA": str(data),
|
||||||
},
|
},
|
||||||
@@ -303,11 +309,50 @@ def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
|||||||
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
|
def _enabled(workspace: Path, plugin: AgentPlugin) -> bool:
|
||||||
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
|
marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled"
|
||||||
try:
|
try:
|
||||||
return marker.is_file() and marker.read_text(encoding="utf-8") == str(plugin.root)
|
if not marker.is_file():
|
||||||
|
return False
|
||||||
|
current = marker.read_text(encoding="utf-8")
|
||||||
|
activation = _activation_marker(plugin)
|
||||||
|
if activation is None:
|
||||||
|
return False
|
||||||
|
if current == activation:
|
||||||
|
return True
|
||||||
|
if current == str(plugin.root):
|
||||||
|
marker.write_text(activation, encoding="utf-8")
|
||||||
|
marker.chmod(0o600)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _activation_marker(plugin: AgentPlugin) -> str | None:
|
||||||
|
"""Bind activation to one immutable package snapshot."""
|
||||||
|
digest = sha256()
|
||||||
|
try:
|
||||||
|
for candidate in sorted(plugin.root.rglob("*")):
|
||||||
|
relative = candidate.relative_to(plugin.root).as_posix()
|
||||||
|
digest.update(relative.encode())
|
||||||
|
if candidate.is_symlink():
|
||||||
|
digest.update(b"\0link\0")
|
||||||
|
digest.update(candidate.readlink().as_posix().encode())
|
||||||
|
elif candidate.is_file():
|
||||||
|
digest.update(b"\0file\0")
|
||||||
|
digest.update(candidate.read_bytes())
|
||||||
|
elif candidate.is_dir():
|
||||||
|
digest.update(b"\0dir\0")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
digest.update(b"\0")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return json.dumps(
|
||||||
|
{"fingerprint": digest.hexdigest(), "root": str(plugin.root)},
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]:
|
||||||
skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
|
skills_root = _contained(plugin_root / "skills", plugin_root, directory=True)
|
||||||
if skills_root is None:
|
if skills_root is None:
|
||||||
|
|||||||
@@ -148,9 +148,20 @@ class _FsTool(Tool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_read(self, path: str) -> Path:
|
def _resolve_read(self, path: str) -> Path:
|
||||||
|
plugin_skill_dirs: list[Path] = []
|
||||||
|
if self._workspace is not None:
|
||||||
|
from nanobot.agent.plugins import enabled_agent_plugin_skills
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin_skill_dirs = [
|
||||||
|
skill.parent
|
||||||
|
for _name, skill in enabled_agent_plugin_skills(Path(self._workspace))
|
||||||
|
]
|
||||||
|
except (OSError, RuntimeError):
|
||||||
|
pass
|
||||||
return self._resolve_with_extra(
|
return self._resolve_with_extra(
|
||||||
path,
|
path,
|
||||||
self._extra_read_allowed_dirs,
|
[*self._extra_read_allowed_dirs, *plugin_skill_dirs],
|
||||||
self._extra_read_allowed_files,
|
self._extra_read_allowed_files,
|
||||||
include_media_dir=True,
|
include_media_dir=True,
|
||||||
extra_files_require_allowed_root=True,
|
extra_files_require_allowed_root=True,
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ from nanobot.agent.plugins import (
|
|||||||
set_agent_plugin_enabled,
|
set_agent_plugin_enabled,
|
||||||
)
|
)
|
||||||
from nanobot.agent.skills import SkillsLoader
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
from nanobot.agent.tools.context import ToolContext
|
||||||
|
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
||||||
|
from nanobot.config.schema import ToolsConfig
|
||||||
|
from nanobot.security.workspace_access import (
|
||||||
|
bind_workspace_scope,
|
||||||
|
reset_workspace_scope,
|
||||||
|
validate_workspace_scope_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -169,6 +177,72 @@ def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
|
|||||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_mcp_namespaces_cannot_shadow_plugin_identities(tmp_path: Path) -> None:
|
||||||
|
single = _plugin(tmp_path, "foo-bar")
|
||||||
|
multi = _plugin(tmp_path, "foo")
|
||||||
|
for root, servers in (
|
||||||
|
(single, {"main": {"type": "stdio", "command": "echo", "args": ["single"]}}),
|
||||||
|
(
|
||||||
|
multi,
|
||||||
|
{
|
||||||
|
"bar": {"type": "stdio", "command": "echo", "args": ["multi"]},
|
||||||
|
"other": {"type": "stdio", "command": "echo"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_write_json(
|
||||||
|
root / "mcp.json",
|
||||||
|
{"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers},
|
||||||
|
)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "foo-bar", True)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "foo", True)
|
||||||
|
|
||||||
|
servers = agent_plugin_mcp_servers(tmp_path)
|
||||||
|
|
||||||
|
assert set(servers) == {"foo-bar", "foo--bar", "foo--other"}
|
||||||
|
assert servers["foo-bar"].args == ["single"]
|
||||||
|
assert servers["foo--bar"].args == ["multi"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restricted_project_can_read_only_enabled_plugin_skill(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
agent_workspace = tmp_path / "agent"
|
||||||
|
project = tmp_path / "project"
|
||||||
|
project.mkdir()
|
||||||
|
plugin = _plugin(agent_workspace)
|
||||||
|
skill = _skill(plugin / "skills", "demo-skill")
|
||||||
|
resource = skill / "reference.md"
|
||||||
|
resource.write_text("plugin reference", encoding="utf-8")
|
||||||
|
ctx = ToolContext(
|
||||||
|
config=ToolsConfig(restrict_to_workspace=True),
|
||||||
|
workspace=str(agent_workspace),
|
||||||
|
)
|
||||||
|
read_tool = ReadFileTool.create(ctx)
|
||||||
|
write_tool = WriteFileTool.create(ctx)
|
||||||
|
set_agent_plugin_enabled(agent_workspace, "demo", True)
|
||||||
|
scope = validate_workspace_scope_payload(
|
||||||
|
{"project_path": str(project), "access_mode": "restricted"},
|
||||||
|
default_workspace=agent_workspace,
|
||||||
|
default_restrict_to_workspace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
token = bind_workspace_scope(scope)
|
||||||
|
try:
|
||||||
|
read_result = await read_tool.execute(path=str(resource))
|
||||||
|
write_result = await write_tool.execute(path=str(resource), content="changed")
|
||||||
|
set_agent_plugin_enabled(agent_workspace, "demo", False)
|
||||||
|
disabled_result = await read_tool.execute(path=str(resource))
|
||||||
|
finally:
|
||||||
|
reset_workspace_scope(token)
|
||||||
|
|
||||||
|
assert "plugin reference" in read_result
|
||||||
|
assert "outside allowed directory" in write_result
|
||||||
|
assert "outside allowed directory" in disabled_result
|
||||||
|
assert resource.read_text(encoding="utf-8") == "plugin reference"
|
||||||
|
|
||||||
|
|
||||||
def test_plugin_state_symlink_cannot_escape_config_root(
|
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -213,3 +287,68 @@ def test_plugin_activation_requires_one_stable_package_identity(tmp_path: Path)
|
|||||||
roots[0].rename(moved)
|
roots[0].rename(moved)
|
||||||
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||||
assert agent_plugin_mcp_servers(tmp_path) == {}
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_path_activation_is_upgraded_to_package_fingerprint(tmp_path: Path) -> None:
|
||||||
|
plugin = _plugin(tmp_path)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
||||||
|
marker = next((tmp_path / "config" / "plugin-data").glob("*/demo/enabled"))
|
||||||
|
marker.write_text(str(plugin), encoding="utf-8")
|
||||||
|
|
||||||
|
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||||
|
assert marker.read_text(encoding="utf-8").startswith('{"fingerprint":')
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_activation_does_not_survive_in_place_contract_replacement(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
plugin = _plugin(tmp_path, "desktop")
|
||||||
|
mcp = plugin / "mcp.json"
|
||||||
|
|
||||||
|
def write_server(marker: str) -> None:
|
||||||
|
_write_json(
|
||||||
|
mcp,
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
"mcpServers": {
|
||||||
|
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
write_server("trusted")
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path)["desktop"].args == ["trusted"]
|
||||||
|
|
||||||
|
write_server("replacement")
|
||||||
|
|
||||||
|
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_activation_does_not_survive_in_place_code_replacement(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
plugin = _plugin(tmp_path, "desktop")
|
||||||
|
executable = plugin / "server.py"
|
||||||
|
executable.write_text("print('trusted')\n", encoding="utf-8")
|
||||||
|
_write_json(
|
||||||
|
plugin / "mcp.json",
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
"mcpServers": {
|
||||||
|
"server": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "python",
|
||||||
|
"args": ["${PLUGIN_ROOT}/server.py"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
||||||
|
|
||||||
|
executable.write_text("print('replacement')\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
|||||||
Reference in New Issue
Block a user