440 lines
15 KiB
Python
440 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from elydora import cli
|
|
from elydora.plugins import _transaction, copilot
|
|
from elydora.plugins.base import InstallConfig
|
|
from elydora.plugins.registry import SUPPORTED_AGENTS
|
|
|
|
|
|
AGENT_ID = "agent-1"
|
|
MISSING = object()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CopilotFixture:
|
|
plugin: copilot.CopilotPlugin
|
|
config: InstallConfig
|
|
home_dir: Path
|
|
copilot_home: Path
|
|
project_dir: Path
|
|
agent_dir: Path
|
|
config_path: Path
|
|
legacy_path: Path
|
|
guard_path: Path
|
|
hook_path: Path
|
|
runtime_config_path: Path
|
|
private_key_path: Path
|
|
|
|
|
|
def write_json_or_text(path: Path, value: object) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
source = value if isinstance(value, str) else json.dumps(value, indent=2)
|
|
path.write_text(source, encoding="utf-8")
|
|
|
|
|
|
def prepare_fixture(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
*,
|
|
user_config: object = MISSING,
|
|
legacy_config: object = MISSING,
|
|
create_guard: bool = True,
|
|
) -> CopilotFixture:
|
|
home_dir = tmp_path / "home with spaces"
|
|
project_dir = tmp_path / "project with spaces"
|
|
copilot_home = home_dir / "custom copilot"
|
|
agent_dir = home_dir / ".elydora" / AGENT_ID
|
|
config_path = copilot_home / "hooks" / "elydora-audit.json"
|
|
legacy_path = project_dir / ".github" / "hooks" / "hooks.json"
|
|
guard_path = agent_dir / "guard.py"
|
|
hook_path = agent_dir / "hook.py"
|
|
agent_dir.mkdir(parents=True)
|
|
project_dir.mkdir(parents=True)
|
|
if create_guard:
|
|
guard_path.write_text(
|
|
"import sys\nsys.stdin.read()\n"
|
|
"sys.stderr.write('Agent is frozen by Elydora.')\n"
|
|
"raise SystemExit(2)\n",
|
|
encoding="utf-8",
|
|
)
|
|
if user_config is not MISSING:
|
|
write_json_or_text(config_path, user_config)
|
|
if legacy_config is not MISSING:
|
|
write_json_or_text(legacy_path, legacy_config)
|
|
|
|
monkeypatch.chdir(project_dir)
|
|
monkeypatch.setattr(copilot, "ELYDORA_DIR", str(agent_dir.parent))
|
|
monkeypatch.setattr(copilot, "_home_dir", lambda: str(home_dir), raising=False)
|
|
monkeypatch.setenv("COPILOT_HOME", str(copilot_home))
|
|
config: InstallConfig = {
|
|
"agent_id": AGENT_ID,
|
|
"agent_name": "copilot",
|
|
"org_id": "org-1",
|
|
"private_key": "test-key",
|
|
"kid": "kid-1",
|
|
"base_url": "https://api.elydora.test",
|
|
"guard_script_path": str(guard_path),
|
|
}
|
|
return CopilotFixture(
|
|
plugin=copilot.CopilotPlugin(),
|
|
config=config,
|
|
home_dir=home_dir,
|
|
copilot_home=copilot_home,
|
|
project_dir=project_dir,
|
|
agent_dir=agent_dir,
|
|
config_path=config_path,
|
|
legacy_path=legacy_path,
|
|
guard_path=guard_path,
|
|
hook_path=hook_path,
|
|
runtime_config_path=agent_dir / "config.json",
|
|
private_key_path=agent_dir / "private.key",
|
|
)
|
|
|
|
|
|
def managed_handler(
|
|
settings: dict[str, Any], event: str, script_name: str
|
|
) -> dict[str, Any]:
|
|
for handler in settings.get("hooks", {}).get(event, []):
|
|
if script_name in str(handler.get("bash")):
|
|
return handler
|
|
raise AssertionError(f"managed {event} handler not found")
|
|
|
|
|
|
def assert_native_handler(handler: dict[str, Any]) -> None:
|
|
assert set(handler) == {"type", "bash", "powershell", "timeoutSec"}
|
|
assert handler["type"] == "command"
|
|
assert handler["timeoutSec"] == 10
|
|
assert Path(sys.executable).name in handler["bash"]
|
|
assert handler["powershell"].startswith("& ")
|
|
assert handler["powershell"].endswith("; exit $LASTEXITCODE")
|
|
|
|
|
|
def legacy_managed_config(
|
|
fixture: CopilotFixture,
|
|
extra_hooks: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"version": 1,
|
|
"hooks": {
|
|
"preToolUse": [{
|
|
"type": "command",
|
|
"bash": f'"{sys.executable}" {fixture.guard_path}',
|
|
"powershell": f'"{sys.executable}" {fixture.guard_path}',
|
|
"timeoutSec": 5,
|
|
}],
|
|
"postToolUse": [{
|
|
"type": "command",
|
|
"bash": str(fixture.hook_path),
|
|
"powershell": str(fixture.hook_path),
|
|
"timeoutSec": 5,
|
|
}],
|
|
**(extra_hooks or {}),
|
|
},
|
|
}
|
|
|
|
|
|
def run_hook(
|
|
handler: dict[str, Any],
|
|
home_dir: Path,
|
|
payload: str,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
if os.name == "nt":
|
|
command = [
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-NonInteractive",
|
|
"-Command",
|
|
handler["powershell"],
|
|
]
|
|
else:
|
|
command = ["/bin/sh", "-c", handler["bash"]]
|
|
return subprocess.run(
|
|
command,
|
|
capture_output=True,
|
|
check=False,
|
|
env={**os.environ, "HOME": str(home_dir), "USERPROFILE": str(home_dir)},
|
|
input=payload,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def test_copilot_is_registered_with_native_user_hooks() -> None:
|
|
assert SUPPORTED_AGENTS["copilot"] == {
|
|
"name": "GitHub Copilot CLI",
|
|
"hook_event": "preToolUse/postToolUse",
|
|
"config_path": "~/.copilot/hooks/elydora-audit.json",
|
|
}
|
|
assert cli.PLUGIN_MAP["copilot"] is copilot.CopilotPlugin
|
|
|
|
|
|
def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_config={
|
|
"version": 1,
|
|
"disableAllHooks": False,
|
|
"hooks": {
|
|
"sessionStart": [{"type": "command", "command": "user-session"}],
|
|
"preToolUse": [{"type": "command", "command": "user-pre"}],
|
|
},
|
|
},
|
|
)
|
|
write_json_or_text(
|
|
fixture.legacy_path,
|
|
legacy_managed_config(fixture, {
|
|
"notification": [{"type": "command", "command": "user-notification"}],
|
|
}),
|
|
)
|
|
|
|
fixture.plugin.install(fixture.config)
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
assert settings["disableAllHooks"] is False
|
|
assert settings["hooks"]["sessionStart"] == [
|
|
{"type": "command", "command": "user-session"}
|
|
]
|
|
assert settings["hooks"]["preToolUse"][0] == {
|
|
"type": "command",
|
|
"command": "user-pre",
|
|
}
|
|
assert len(settings["hooks"]["preToolUse"]) == 2
|
|
assert len(settings["hooks"]["postToolUse"]) == 1
|
|
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
|
|
assert_native_handler(managed_handler(settings, "postToolUse", "hook.py"))
|
|
legacy = json.loads(fixture.legacy_path.read_text(encoding="utf-8"))
|
|
assert legacy == {
|
|
"version": 1,
|
|
"hooks": {
|
|
"notification": [{"type": "command", "command": "user-notification"}],
|
|
},
|
|
}
|
|
assert fixture.runtime_config_path.is_file()
|
|
assert fixture.private_key_path.read_text(encoding="utf-8") == "test-key"
|
|
assert fixture.hook_path.is_file()
|
|
|
|
|
|
def test_migration_removes_legacy_file_owned_entirely_by_elydora(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
write_json_or_text(fixture.legacy_path, legacy_managed_config(fixture))
|
|
|
|
fixture.plugin.install(fixture.config)
|
|
|
|
assert fixture.legacy_path.exists() is False
|
|
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
|
|
|
|
|
|
def test_commands_block_and_forward_native_payload_byte_for_byte(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.plugin.install(fixture.config)
|
|
capture_path = tmp_path / "captured-event.json"
|
|
fixture.hook_path.write_text(
|
|
"from pathlib import Path\nimport sys\n"
|
|
f"Path({str(capture_path)!r}).write_text(sys.stdin.read(), encoding='utf-8')\n",
|
|
encoding="utf-8",
|
|
)
|
|
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
pre_payload = json.dumps({
|
|
"sessionId": "session-1",
|
|
"timestamp": 1,
|
|
"cwd": str(fixture.project_dir),
|
|
"toolName": "powershell",
|
|
"toolArgs": {"command": "Get-ChildItem"},
|
|
}, separators=(",", ":"))
|
|
guard = managed_handler(settings, "preToolUse", "guard.py")
|
|
guard_result = run_hook(guard, fixture.home_dir, pre_payload)
|
|
assert guard_result.returncode == 2, guard_result.stderr
|
|
assert "Agent is frozen by Elydora" in guard_result.stderr
|
|
|
|
post_payload = json.dumps({
|
|
"sessionId": "session-1",
|
|
"timestamp": 2,
|
|
"cwd": str(fixture.project_dir),
|
|
"toolName": "powershell",
|
|
"toolArgs": {"command": "Get-ChildItem"},
|
|
"toolResult": {"output": "ok"},
|
|
}, separators=(",", ":"))
|
|
audit = managed_handler(settings, "postToolUse", "hook.py")
|
|
audit_result = run_hook(audit, fixture.home_dir, post_payload)
|
|
assert audit_result.returncode == 0, audit_result.stderr
|
|
assert capture_path.read_text(encoding="utf-8") == post_payload
|
|
|
|
|
|
def test_empty_home_override_uses_official_default(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("COPILOT_HOME", "")
|
|
fixture.plugin.install(fixture.config)
|
|
default_path = fixture.home_dir / ".copilot" / "hooks" / "elydora-audit.json"
|
|
settings = json.loads(default_path.read_text(encoding="utf-8"))
|
|
assert_native_handler(managed_handler(settings, "preToolUse", "guard.py"))
|
|
assert str(default_path) in fixture.plugin.status()["details"]
|
|
|
|
|
|
def test_status_requires_complete_pair_and_valid_runtime_identity(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.plugin.install(fixture.config)
|
|
assert fixture.plugin.status()["installed"] is True
|
|
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
del settings["hooks"]["postToolUse"]
|
|
write_json_or_text(fixture.config_path, settings)
|
|
assert fixture.plugin.status()["installed"] is False
|
|
|
|
fixture.plugin.install(fixture.config)
|
|
fixture.runtime_config_path.write_text("{ malformed", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="parse Elydora runtime config"):
|
|
fixture.plugin.status()
|
|
|
|
|
|
def test_uninstall_removes_exact_ownership_and_preserves_user_entries(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_config={
|
|
"version": 1,
|
|
"hooks": {"notification": [{"type": "command", "command": "keep"}]},
|
|
},
|
|
)
|
|
fixture.plugin.install(fixture.config)
|
|
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
settings["hooks"]["preToolUse"].append({
|
|
"type": "command",
|
|
"bash": f"{sys.executable} {fixture.agent_dir.parent / 'agent-10' / 'guard.py'}",
|
|
"powershell": "user-decoy",
|
|
"timeoutSec": 10,
|
|
})
|
|
write_json_or_text(fixture.config_path, settings)
|
|
|
|
fixture.plugin.uninstall(AGENT_ID.upper() if os.name == "nt" else AGENT_ID)
|
|
|
|
remaining = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
|
assert remaining["hooks"]["notification"] == [
|
|
{"type": "command", "command": "keep"}
|
|
]
|
|
assert "postToolUse" not in remaining["hooks"]
|
|
assert len(remaining["hooks"]["preToolUse"]) == 1
|
|
assert "agent-10" in remaining["hooks"]["preToolUse"][0]["bash"]
|
|
|
|
|
|
def test_uninstall_leaves_absent_sources_absent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.plugin.uninstall(AGENT_ID)
|
|
assert fixture.config_path.exists() is False
|
|
assert fixture.legacy_path.exists() is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"existing",
|
|
[
|
|
"{ malformed",
|
|
{"hooks": {}},
|
|
{"version": 2, "hooks": {}},
|
|
{"version": 1, "hooks": None},
|
|
{"version": 1, "hooks": {"preToolUse": None}},
|
|
{"version": 1, "hooks": {"preToolUse": [None]}},
|
|
],
|
|
)
|
|
def test_invalid_config_is_preserved_before_runtime_writes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
existing: object,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, user_config=existing)
|
|
original = fixture.config_path.read_text(encoding="utf-8")
|
|
with pytest.raises((ValueError, json.JSONDecodeError)):
|
|
fixture.plugin.install(fixture.config)
|
|
assert fixture.config_path.read_text(encoding="utf-8") == original
|
|
assert fixture.runtime_config_path.exists() is False
|
|
assert fixture.private_key_path.exists() is False
|
|
assert fixture.hook_path.exists() is False
|
|
|
|
|
|
def test_missing_guard_is_rejected_before_creating_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path, create_guard=False)
|
|
with pytest.raises(FileNotFoundError, match="guard runtime is missing"):
|
|
fixture.plugin.install(fixture.config)
|
|
assert fixture.config_path.exists() is False
|
|
assert fixture.runtime_config_path.exists() is False
|
|
assert fixture.private_key_path.exists() is False
|
|
assert fixture.hook_path.exists() is False
|
|
|
|
|
|
def test_transaction_rolls_back_every_file_after_commit_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(
|
|
monkeypatch,
|
|
tmp_path,
|
|
user_config={"version": 1, "hooks": {"notification": []}},
|
|
legacy_config={"version": 1, "hooks": {"sessionStart": []}},
|
|
)
|
|
user_before = fixture.config_path.read_text(encoding="utf-8")
|
|
legacy_before = fixture.legacy_path.read_text(encoding="utf-8")
|
|
original_commit = _transaction._commit
|
|
commits = 0
|
|
|
|
def fail_after_first_commit(staged: Any) -> None:
|
|
nonlocal commits
|
|
original_commit(staged)
|
|
commits += 1
|
|
if commits == 1:
|
|
raise OSError("injected commit failure")
|
|
|
|
monkeypatch.setattr(_transaction, "_commit", fail_after_first_commit)
|
|
with pytest.raises(OSError, match="injected commit failure"):
|
|
fixture.plugin.install(fixture.config)
|
|
assert fixture.config_path.read_text(encoding="utf-8") == user_before
|
|
assert fixture.legacy_path.read_text(encoding="utf-8") == legacy_before
|
|
assert fixture.runtime_config_path.exists() is False
|
|
assert fixture.private_key_path.exists() is False
|
|
assert fixture.hook_path.exists() is False
|
|
|
|
|
|
def test_atomic_writes_leave_no_transaction_files(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
fixture = prepare_fixture(monkeypatch, tmp_path)
|
|
fixture.plugin.install(fixture.config)
|
|
for directory in (fixture.agent_dir, fixture.config_path.parent):
|
|
assert all(
|
|
path.suffix not in {".tmp", ".rollback"}
|
|
for path in directory.iterdir()
|
|
)
|