Files
Pyra/tests/security/test_plugin_isolation.py
curo1305 c0c0156468 feat(plugins): Stage 2.1 — plugin framework and AI tool-use
Introduces a standalone plugin system where every integration lives as
an independent Python script in ~/.pyra/plugins/, not hardcoded in core.

Plugin framework (src/pyra/plugins/):
- base.py: Tool dataclass, PyraPlugin Protocol, BasePlugin helper
- loader.py: importlib-based discovery; one bad plugin never crashes pyra
- registry.py: singleton aggregating tools, slash commands, system prompts
- executor.py: approval gate — scans args, prompts y/N, scans result, logs
- install.py: copies bundled_plugins/ to ~/.pyra/plugins/ on install

Chat integration:
- AI tool-use loop (litellm function calling, up to 10 iterations)
- Plugin system prompt additions injected per session
- Plugin slash commands merged with static commands

CLI additions:
- pyra plugin list/install/enable/disable/setup
- pyra daemon start/stop/status/restart/install/uninstall (stubs for 2.4)

Config: PluginConfig + DaemonConfig added to PyraConfig (backwards-compatible)
Bootstrap: ~/.pyra/plugins/ and ~/.pyra/logs/ created on startup
Security: tool args and results always injection-scanned; plugin dirs
validated with assert_safe_path() before loading (symlink protection)

Tests: 37 new tests (loader, registry, executor, plugin isolation security)
161 total, all passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:35:20 +02:00

127 lines
4.4 KiB
Python

"""Security tests: plugins cannot access the vault."""
import json
from pathlib import Path
import pytest
from pyra.security.boundaries import VaultAccessError
from pyra.plugins.loader import load_plugin_by_name
def _make_plugin(plugins_dir: Path, name: str, code: str) -> Path:
d = plugins_dir / name
d.mkdir(parents=True)
(d / "manifest.json").write_text(json.dumps({"name": name, "version": "1.0.0"}))
(d / "plugin.py").write_text(code)
return d
# ── vault access via on_load ───────────────────────────────────────────────────
def test_plugin_cannot_receive_vault_path_via_vault_reader(tmp_pyra_home, tmp_path):
"""vault_reader returns None for any key not in the vault — plugins can't fish for paths."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
code = """\
from pyra.plugins.base import BasePlugin
class _P(BasePlugin):
name = "vault_fisher"
description = "tries to get vault contents"
version = "1.0.0"
found = None
def on_load(self, vault_reader):
# Plugin can only call vault_reader with a string key, gets None back if key absent
self.found = vault_reader("plugin:vault_fisher:secret")
def get_plugin():
return _P()
"""
_make_plugin(plugins_dir, "vault_fisher", code)
plugin = load_plugin_by_name("vault_fisher", plugins_dir)
assert plugin is not None
# vault_reader returns None because the key doesn't exist — no vault data exposed
assert plugin.found is None # type: ignore[attr-defined]
def test_plugin_symlink_in_plugins_dir_is_blocked(tmp_pyra_home, tmp_path):
"""A plugin directory that is a symlink pointing inside the vault is blocked."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
# Create a symlink from plugins/evil -> vault/
evil_link = plugins_dir / "evil"
evil_link.symlink_to(tmp_pyra_home / "vault")
# Loading should fail because assert_safe_path blocks vault-pointing paths
result = load_plugin_by_name("evil", plugins_dir)
assert result is None
def test_plugin_on_load_receives_vault_reader_callable(tmp_pyra_home, tmp_path):
"""on_load receives vault_reader callable. Plugin can only access keys it knows the name of —
the trust model relies on naming convention (plugin:name:key), not code-level sandboxing."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
code = """\
from pyra.plugins.base import BasePlugin
class _P(BasePlugin):
name = "vault_test"
description = "tests vault_reader"
version = "1.0.0"
got_none = None
def on_load(self, vault_reader):
# Asking for a key that doesn't exist returns None
self.got_none = vault_reader("plugin:vault_test:nonexistent_key")
def get_plugin():
return _P()
"""
_make_plugin(plugins_dir, "vault_test", code)
plugin = load_plugin_by_name("vault_test", plugins_dir)
assert plugin is not None
# Call on_load manually (normally done by registry.load_all)
from pyra.vault.reader import get_key
plugin.on_load(get_key)
# Non-existent key returns None — plugin gets no data
assert plugin.got_none is None # type: ignore[attr-defined]
def test_assert_safe_path_blocks_vault_directory(tmp_pyra_home):
"""Core invariant: assert_safe_path always blocks paths inside vault/."""
from pyra.security.boundaries import assert_safe_path
vault_path = tmp_pyra_home / "vault" / "secrets" / "api_keys.json"
with pytest.raises(VaultAccessError):
assert_safe_path(vault_path)
def test_plugin_load_does_not_grant_vault_path_access(tmp_pyra_home, tmp_path):
"""A plugin that calls open() on the vault path directly gets a file not found or
permission error — but assert_safe_path isn't called inside plugin code by the core.
This test verifies the loader path itself goes through assert_safe_path."""
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
# Plugin dir is clean (not pointing at vault) — load should succeed
code = """\
from pyra.plugins.base import BasePlugin
class _P(BasePlugin):
name = "normal_plugin"
description = "Normal plugin"
version = "1.0.0"
def get_plugin():
return _P()
"""
_make_plugin(plugins_dir, "normal_plugin", code)
plugin = load_plugin_by_name("normal_plugin", plugins_dir)
assert plugin is not None
assert plugin.name == "normal_plugin"