fix: make Cursor hooks fail closed
This commit is contained in:
1 parent
6cb7ef188d
commit
c3602da5f3
15 files changed
+1397
-718
No files matched your search
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from threading import Thread
|
||||
from typing import Any, Mapping
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins.cursor import CursorPlugin
|
||||
from elydora.utils import base64url_encode
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
VALID_PRIVATE_KEY = base64url_encode(bytes(range(32)))
|
||||
|
||||
|
||||
@dataclass
|
||||
class CursorFixture:
|
||||
home_dir: Path
|
||||
config_path: Path
|
||||
agent_dir: Path
|
||||
guard_path: Path
|
||||
hook_path: Path
|
||||
runtime_config_path: Path
|
||||
private_key_path: Path
|
||||
plugin: CursorPlugin
|
||||
config: dict[str, str]
|
||||
|
||||
def install(self) -> None:
|
||||
self.plugin.install(self.config)
|
||||
|
||||
def settings(self) -> dict[str, Any]:
|
||||
return json.loads(self.config_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class ElydoraApiHandler(BaseHTTPRequestHandler):
|
||||
agent_status = "active"
|
||||
get_status = 200
|
||||
post_status = 201
|
||||
get_paths: list[str] = []
|
||||
operations: list[dict[str, Any]] = []
|
||||
authorizations: list[str | None] = []
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
cls.agent_status = "active"
|
||||
cls.get_status = 200
|
||||
cls.post_status = 201
|
||||
cls.get_paths = []
|
||||
cls.operations = []
|
||||
cls.authorizations = []
|
||||
|
||||
def _respond(self, status: int, value: object) -> None:
|
||||
body = json.dumps(value).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
type(self).get_paths.append(self.path)
|
||||
type(self).authorizations.append(self.headers.get("Authorization"))
|
||||
self._respond(
|
||||
type(self).get_status,
|
||||
{"agent": {"status": type(self).agent_status}},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
value = json.loads(self.rfile.read(length))
|
||||
assert isinstance(value, dict)
|
||||
type(self).operations.append(value)
|
||||
type(self).authorizations.append(self.headers.get("Authorization"))
|
||||
self._respond(type(self).post_status, {"ok": True})
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def start_api_server() -> ThreadingHTTPServer:
|
||||
ElydoraApiHandler.reset()
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), ElydoraApiHandler)
|
||||
Thread(target=server.serve_forever, daemon=True).start()
|
||||
return server
|
||||
|
||||
|
||||
def server_base_url(server: ThreadingHTTPServer) -> str:
|
||||
return f"http://127.0.0.1:{server.server_port}"
|
||||
|
||||
|
||||
def write_text(path: Path, source: str, mode: int = 0o600) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(source, encoding="utf-8")
|
||||
os.chmod(path, mode)
|
||||
|
||||
|
||||
def write_json(path: Path, value: object, mode: int = 0o600) -> None:
|
||||
source = value if isinstance(value, str) else json.dumps(value, indent=2) + "\n"
|
||||
write_text(path, source, mode)
|
||||
|
||||
|
||||
def prepare_fixture(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
existing_config: object | None = None,
|
||||
) -> CursorFixture:
|
||||
home_dir = tmp_path / "home with spaces and 'quote"
|
||||
config_path = home_dir / ".cursor" / "hooks.json"
|
||||
agent_dir = home_dir / ".elydora" / AGENT_ID
|
||||
guard_path = agent_dir / "guard.py"
|
||||
hook_path = agent_dir / "hook.py"
|
||||
runtime_config_path = agent_dir / "config.json"
|
||||
private_key_path = agent_dir / "private.key"
|
||||
monkeypatch.setenv("HOME", str(home_dir))
|
||||
monkeypatch.setenv("USERPROFILE", str(home_dir))
|
||||
agent_dir.mkdir(parents=True)
|
||||
if existing_config is not None:
|
||||
write_json(config_path, existing_config)
|
||||
return CursorFixture(
|
||||
home_dir=home_dir,
|
||||
config_path=config_path,
|
||||
agent_dir=agent_dir,
|
||||
guard_path=guard_path,
|
||||
hook_path=hook_path,
|
||||
runtime_config_path=runtime_config_path,
|
||||
private_key_path=private_key_path,
|
||||
plugin=CursorPlugin(),
|
||||
config={
|
||||
"org_id": "org-1",
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_name": "cursor",
|
||||
"private_key": VALID_PRIVATE_KEY,
|
||||
"kid": "key-1",
|
||||
"token": "token-1",
|
||||
"base_url": "https://api.elydora.com",
|
||||
"guard_script_path": str(guard_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def managed_handler(
|
||||
settings: Mapping[str, Any],
|
||||
event: str,
|
||||
script: str,
|
||||
) -> dict[str, Any]:
|
||||
handlers = settings["hooks"][event]
|
||||
return next(handler for handler in handlers if script in handler.get("command", ""))
|
||||
|
||||
|
||||
def assert_native_handler(handler: dict[str, Any]) -> None:
|
||||
assert set(handler) == {"command", "timeout", "failClosed"}
|
||||
assert handler["timeout"] == 10
|
||||
assert handler["failClosed"] is True
|
||||
assert sys.executable.lower() in handler["command"].lower()
|
||||
if os.name == "nt":
|
||||
assert handler["command"].startswith("& '")
|
||||
assert handler["command"].endswith("; exit $LASTEXITCODE")
|
||||
else:
|
||||
assert handler["command"].startswith("'")
|
||||
|
||||
|
||||
def run_handler(
|
||||
handler: Mapping[str, Any],
|
||||
payload: bytes,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
if os.name == "nt":
|
||||
arguments = [
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
handler["command"],
|
||||
]
|
||||
else:
|
||||
arguments = ["/bin/sh", "-c", handler["command"]]
|
||||
return subprocess.run(
|
||||
arguments,
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, **(environment or {})},
|
||||
)
|
||||
|
||||
|
||||
def symlink_or_skip(target: Path, link: Path, *, directory: bool = False) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=directory)
|
||||
except OSError as error:
|
||||
pytest.skip(f"Symbolic links unavailable: {error}")
|
||||
@@ -24,6 +24,7 @@ from elydora.plugins import (
|
||||
opencode,
|
||||
)
|
||||
from elydora.plugins.hook_template import generate_hook_script
|
||||
from elydora.utils import base64url_decode
|
||||
|
||||
|
||||
PRIVATE_KEY = base64.urlsafe_b64encode(bytes([7]) * 32).rstrip(b"=").decode()
|
||||
@@ -341,7 +342,7 @@ def test_generated_hook_reads_private_key_from_private_file(
|
||||
|
||||
exec(compile(script, "hook.py", "exec"), namespace)
|
||||
|
||||
assert namespace["read_private_key"]() == PRIVATE_KEY
|
||||
assert namespace["read_private_key"]() == base64url_decode(PRIVATE_KEY)
|
||||
assert PRIVATE_KEY not in script
|
||||
assert "PRIVATE_KEY_PATH" in script
|
||||
|
||||
@@ -393,7 +394,8 @@ def test_legacy_plugins_persist_one_owner_only_private_key(
|
||||
runtime_root = case_root / ".elydora"
|
||||
guard_path = runtime_root / "agent-1" / "guard.py"
|
||||
guard_path.parent.mkdir(parents=True)
|
||||
guard_path.write_text("pass\n", encoding="utf-8")
|
||||
if module is not cursor:
|
||||
guard_path.write_text("pass\n", encoding="utf-8")
|
||||
if module is cursor:
|
||||
monkeypatch.setenv("HOME", str(case_root))
|
||||
monkeypatch.setenv("USERPROFILE", str(case_root))
|
||||
|
||||
+115
-360
@@ -1,156 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from elydora.plugins import cursor
|
||||
from elydora.plugins.cursor import CursorPlugin
|
||||
from elydora.plugins.hook_template import generate_guard_script
|
||||
from cursor_support import (
|
||||
AGENT_ID,
|
||||
assert_native_handler,
|
||||
managed_handler,
|
||||
prepare_fixture,
|
||||
write_json,
|
||||
)
|
||||
from elydora.plugins.registry import SUPPORTED_AGENTS
|
||||
|
||||
|
||||
AGENT_ID = "agent-1"
|
||||
EVENTS = (
|
||||
("preToolUse", "guard.py"),
|
||||
("postToolUse", "hook.py"),
|
||||
("postToolUseFailure", "hook.py"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CursorFixture:
|
||||
home_dir: Path
|
||||
config_path: Path
|
||||
agent_dir: Path
|
||||
guard_path: Path
|
||||
hook_path: Path
|
||||
runtime_config_path: Path
|
||||
private_key_path: Path
|
||||
plugin: CursorPlugin
|
||||
config: dict[str, str]
|
||||
|
||||
def install(self) -> None:
|
||||
self.plugin.install(self.config)
|
||||
|
||||
|
||||
def _write_json(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) + "\n"
|
||||
path.write_text(source, encoding="utf-8")
|
||||
|
||||
|
||||
def _prepare_fixture(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
*,
|
||||
existing_config: object | None = None,
|
||||
create_guard: bool = True,
|
||||
) -> CursorFixture:
|
||||
home_dir = tmp_path / "home with spaces and 'quote"
|
||||
config_path = home_dir / ".cursor" / "hooks.json"
|
||||
agent_dir = home_dir / ".elydora" / AGENT_ID
|
||||
guard_path = agent_dir / "guard.py"
|
||||
hook_path = agent_dir / "hook.py"
|
||||
runtime_config_path = agent_dir / "config.json"
|
||||
private_key_path = agent_dir / "private.key"
|
||||
monkeypatch.setenv("HOME", str(home_dir))
|
||||
monkeypatch.setenv("USERPROFILE", str(home_dir))
|
||||
monkeypatch.setattr(cursor, "SETTINGS_PATH", str(config_path), raising=False)
|
||||
monkeypatch.setattr(
|
||||
cursor,
|
||||
"ELYDORA_DIR",
|
||||
str(home_dir / ".elydora"),
|
||||
raising=False,
|
||||
)
|
||||
agent_dir.mkdir(parents=True)
|
||||
if create_guard:
|
||||
guard_path.write_text(
|
||||
generate_guard_script("cursor", AGENT_ID),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if existing_config is not None:
|
||||
_write_json(config_path, existing_config)
|
||||
return CursorFixture(
|
||||
home_dir=home_dir,
|
||||
config_path=config_path,
|
||||
agent_dir=agent_dir,
|
||||
guard_path=guard_path,
|
||||
hook_path=hook_path,
|
||||
runtime_config_path=runtime_config_path,
|
||||
private_key_path=private_key_path,
|
||||
plugin=CursorPlugin(),
|
||||
config={
|
||||
"org_id": "org-1",
|
||||
"agent_id": AGENT_ID,
|
||||
"agent_name": "cursor",
|
||||
"private_key": "private-key",
|
||||
"kid": "key-1",
|
||||
"token": "token-1",
|
||||
"base_url": "https://api.elydora.com",
|
||||
"guard_script_path": str(guard_path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _managed_handler(settings: Mapping[str, Any], event: str, script: str) -> dict:
|
||||
handlers = settings["hooks"][event]
|
||||
return next(handler for handler in handlers if script in handler.get("command", ""))
|
||||
|
||||
|
||||
def _assert_native_handler(handler: dict) -> None:
|
||||
assert set(handler) == {"command", "timeout", "failClosed"}
|
||||
assert handler["timeout"] == 10
|
||||
assert handler["failClosed"] is True
|
||||
assert sys.executable.lower() in handler["command"].lower()
|
||||
if os.name == "nt":
|
||||
assert handler["command"].startswith("& '")
|
||||
assert handler["command"].endswith("; exit $LASTEXITCODE")
|
||||
else:
|
||||
assert handler["command"].startswith("'")
|
||||
|
||||
|
||||
def _run_handler(
|
||||
handler: Mapping[str, Any], payload: bytes,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
if os.name == "nt":
|
||||
arguments = [
|
||||
"powershell.exe", "-NoProfile", "-NonInteractive",
|
||||
"-Command", handler["command"],
|
||||
]
|
||||
else:
|
||||
arguments = ["/bin/sh", "-c", handler["command"]]
|
||||
return subprocess.run(
|
||||
arguments,
|
||||
input=payload,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env={**os.environ, **(environment or {})},
|
||||
)
|
||||
|
||||
|
||||
def _symlink_or_skip(target: Path, link: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except OSError as error:
|
||||
pytest.skip(f"Symbolic links unavailable: {error}")
|
||||
|
||||
|
||||
def test_cursor_registry_declares_both_native_events() -> None:
|
||||
def test_cursor_registry_declares_complete_native_contract() -> None:
|
||||
assert SUPPORTED_AGENTS["cursor"] == {
|
||||
"name": "Cursor", "hook_event": "PreToolUse/PostToolUse",
|
||||
"name": "Cursor",
|
||||
"hook_event": "preToolUse/postToolUse/postToolUseFailure",
|
||||
"config_path": "~/.cursor/hooks.json",
|
||||
}
|
||||
|
||||
|
||||
def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
_write_json(
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(
|
||||
fixture.config_path,
|
||||
{
|
||||
"description": "user-owned",
|
||||
@@ -168,92 +57,29 @@ def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
|
||||
fixture.install()
|
||||
fixture.install()
|
||||
|
||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
settings = fixture.settings()
|
||||
assert settings["version"] == 1
|
||||
assert settings["description"] == "user-owned"
|
||||
assert settings["hooks"]["sessionStart"] == [{"command": "user-session"}]
|
||||
assert settings["hooks"]["preToolUse"][0] == {"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"))
|
||||
def test_handlers_block_frozen_agent_and_forward_official_input_byte_for_byte(
|
||||
assert len(settings["hooks"]["postToolUseFailure"]) == 1
|
||||
for event, script in EVENTS:
|
||||
assert_native_handler(managed_handler(settings, event, script))
|
||||
|
||||
|
||||
def test_status_requires_complete_contract_identity_and_physical_runtimes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
guard = _managed_handler(settings, "preToolUse", "guard.py")
|
||||
audit = _managed_handler(settings, "postToolUse", "hook.py")
|
||||
_write_json(
|
||||
fixture.agent_dir / "status-cache.json",
|
||||
{"status": "active", "cached_at": time.time()},
|
||||
)
|
||||
assert json.loads(_run_handler(guard, b"{}\n").stdout) == {"permission": "allow"}
|
||||
_write_json(
|
||||
fixture.agent_dir / "status-cache.json",
|
||||
{"status": "frozen", "cached_at": time.time()},
|
||||
)
|
||||
pre_payload = (
|
||||
json.dumps(
|
||||
{
|
||||
"conversation_id": "conversation-1",
|
||||
"generation_id": "generation-1",
|
||||
"hook_event_name": "preToolUse",
|
||||
"tool_name": "Shell",
|
||||
"tool_input": {"command": "Get-ChildItem"},
|
||||
"tool_use_id": "call-1",
|
||||
"cwd": str(tmp_path),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode()
|
||||
guard_result = _run_handler(guard, pre_payload)
|
||||
assert guard_result.returncode == 2
|
||||
assert b"frozen by Elydora" in guard_result.stderr
|
||||
capture_path = tmp_path / "captured-event.json"
|
||||
fixture.hook_path.write_text(
|
||||
"import os, pathlib, sys\n"
|
||||
"pathlib.Path(os.environ['ELYDORA_CAPTURE']).write_bytes(sys.stdin.buffer.read())\n"
|
||||
"print('{}')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
post_payload = (
|
||||
json.dumps(
|
||||
{
|
||||
"conversation_id": "conversation-1",
|
||||
"generation_id": "generation-1",
|
||||
"hook_event_name": "postToolUse",
|
||||
"tool_name": "Shell",
|
||||
"tool_input": {"command": "Get-ChildItem"},
|
||||
"tool_output": '{"exitCode":0,"stdout":"ok"}',
|
||||
"tool_use_id": "call-1",
|
||||
"cwd": str(tmp_path),
|
||||
"duration": 42,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode()
|
||||
audit_result = _run_handler(audit, post_payload, {"ELYDORA_CAPTURE": str(capture_path)})
|
||||
assert audit_result.returncode == 0
|
||||
assert json.loads(audit_result.stdout) == {}
|
||||
assert capture_path.read_bytes() == post_payload
|
||||
|
||||
|
||||
def test_status_requires_exact_pair_identity_and_physical_runtimes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
assert fixture.plugin.status()["installed"] is True
|
||||
|
||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
_managed_handler(settings, "preToolUse", "guard.py")["failClosed"] = False
|
||||
_write_json(fixture.config_path, settings)
|
||||
settings = fixture.settings()
|
||||
settings["hooks"].pop("postToolUseFailure")
|
||||
write_json(fixture.config_path, settings)
|
||||
assert fixture.plugin.status()["installed"] is False
|
||||
|
||||
fixture.install()
|
||||
@@ -262,21 +88,21 @@ def test_status_requires_exact_pair_identity_and_physical_runtimes(
|
||||
assert fixture.plugin.status()["installed"] is False
|
||||
fixture.hook_path.write_text(hook_source, encoding="utf-8")
|
||||
|
||||
_write_json(
|
||||
write_json(
|
||||
fixture.runtime_config_path,
|
||||
{"agent_id": "another-agent", "agent_name": "cursor"},
|
||||
)
|
||||
assert fixture.plugin.status()["installed"] is False
|
||||
fixture.runtime_config_path.write_text("{ malformed", encoding="utf-8")
|
||||
write_json(fixture.runtime_config_path, "{ malformed")
|
||||
with pytest.raises(ValueError, match="parse Elydora runtime config"):
|
||||
fixture.plugin.status()
|
||||
|
||||
|
||||
def test_uninstall_uses_exact_ownership_and_preserves_user_entries(
|
||||
def test_uninstall_uses_exact_ownership_for_all_events(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
existing_config={
|
||||
@@ -285,31 +111,28 @@ def test_uninstall_uses_exact_ownership_and_preserves_user_entries(
|
||||
},
|
||||
)
|
||||
fixture.install()
|
||||
settings = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
other_guard = deepcopy(_managed_handler(settings, "preToolUse", "guard.py"))
|
||||
other_audit = deepcopy(_managed_handler(settings, "postToolUse", "hook.py"))
|
||||
other_guard["command"] = other_guard["command"].replace(AGENT_ID, "agent-10")
|
||||
other_audit["command"] = other_audit["command"].replace(AGENT_ID, "agent-10")
|
||||
settings["hooks"]["preToolUse"].extend(
|
||||
[
|
||||
other_guard,
|
||||
{
|
||||
"command": "echo elydora agent-1 guard.py",
|
||||
"timeout": 10,
|
||||
"failClosed": True,
|
||||
},
|
||||
]
|
||||
settings = fixture.settings()
|
||||
for event, script in EVENTS:
|
||||
other = deepcopy(managed_handler(settings, event, script))
|
||||
other["command"] = other["command"].replace(AGENT_ID, "agent-10")
|
||||
settings["hooks"][event].append(other)
|
||||
settings["hooks"]["preToolUse"].append(
|
||||
{
|
||||
"command": "echo elydora agent-1 guard.py",
|
||||
"timeout": 10,
|
||||
"failClosed": True,
|
||||
}
|
||||
)
|
||||
settings["hooks"]["postToolUse"].append(other_audit)
|
||||
_write_json(fixture.config_path, settings)
|
||||
write_json(fixture.config_path, settings)
|
||||
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
|
||||
remaining = json.loads(fixture.config_path.read_text(encoding="utf-8"))
|
||||
remaining = fixture.settings()
|
||||
assert remaining["hooks"]["sessionStart"] == [{"command": "keep"}]
|
||||
assert len(remaining["hooks"]["preToolUse"]) == 2
|
||||
assert len(remaining["hooks"]["postToolUse"]) == 1
|
||||
assert "agent-10" in remaining["hooks"]["preToolUse"][0]["command"]
|
||||
assert len(remaining["hooks"]["postToolUseFailure"]) == 1
|
||||
assert "agent-10" in remaining["hooks"]["postToolUse"][0]["command"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -331,170 +154,102 @@ def test_invalid_config_is_preserved_before_runtime_writes(
|
||||
tmp_path: Path,
|
||||
existing_config: str,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
existing_config=existing_config,
|
||||
)
|
||||
original_guard = fixture.guard_path.read_text(encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == existing_config
|
||||
assert fixture.guard_path.read_text(encoding="utf-8") == original_guard
|
||||
assert fixture.hook_path.exists() is False
|
||||
assert fixture.runtime_config_path.exists() is False
|
||||
assert fixture.private_key_path.exists() is False
|
||||
for path in (
|
||||
fixture.guard_path,
|
||||
fixture.hook_path,
|
||||
fixture.runtime_config_path,
|
||||
fixture.private_key_path,
|
||||
):
|
||||
assert path.exists() is False
|
||||
|
||||
|
||||
def test_install_creates_missing_guard_and_rejects_unmanaged_path(
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "message"),
|
||||
[
|
||||
("org_id", "", "org_id is required"),
|
||||
("kid", "", "kid is required"),
|
||||
("agent_name", "codex", "requires agent_name cursor"),
|
||||
("private_key", "invalid", "canonical 32-byte base64url"),
|
||||
("base_url", "relative", "absolute HTTP or HTTPS URL"),
|
||||
("token", 42, "token must be a string"),
|
||||
],
|
||||
)
|
||||
def test_invalid_install_values_fail_before_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
field: str,
|
||||
value: Any,
|
||||
message: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.config[field] = value # type: ignore[assignment]
|
||||
|
||||
with pytest.raises(ValueError, match=message):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.config_path.exists() is False
|
||||
assert list(fixture.agent_dir.iterdir()) == []
|
||||
|
||||
|
||||
def test_install_creates_all_runtimes_and_rejects_unmanaged_guard(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path, create_guard=False)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
assert fixture.guard_path.is_file()
|
||||
for path in (
|
||||
fixture.guard_path,
|
||||
fixture.hook_path,
|
||||
fixture.runtime_config_path,
|
||||
fixture.private_key_path,
|
||||
):
|
||||
assert path.is_file()
|
||||
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path / "unmanaged", create_guard=False)
|
||||
unmanaged = fixture.home_dir / "unmanaged-guard.py"
|
||||
other = prepare_fixture(monkeypatch, tmp_path / "unmanaged")
|
||||
unmanaged = other.home_dir / "unmanaged-guard.py"
|
||||
unmanaged.write_text("pass\n", encoding="utf-8")
|
||||
fixture.config["guard_script_path"] = str(unmanaged)
|
||||
other.config["guard_script_path"] = str(unmanaged)
|
||||
with pytest.raises(ValueError, match="managed agent directory"):
|
||||
fixture.install()
|
||||
assert fixture.config_path.exists() is False
|
||||
other.install()
|
||||
assert other.config_path.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runtime_name", ["guard.py", "hook.py"])
|
||||
def test_symbolic_link_runtime_is_rejected_before_config_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
runtime_name: str,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
runtime_path = fixture.agent_dir / runtime_name
|
||||
target = fixture.home_dir / f"{runtime_name}.target"
|
||||
target.write_text("pass\n", encoding="utf-8")
|
||||
if runtime_path.exists():
|
||||
runtime_path.unlink()
|
||||
_symlink_or_skip(target, runtime_path)
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.config_path.exists() is False
|
||||
assert target.read_text(encoding="utf-8") == "pass\n"
|
||||
|
||||
|
||||
def test_symbolic_link_config_is_rejected_and_target_is_preserved(
|
||||
def test_uninstall_removes_entirely_managed_config_and_preserves_absence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
target = fixture.home_dir / "cursor-hooks.target.json"
|
||||
original = '{"version":1,"hooks":{}}\n'
|
||||
target.write_text(original, encoding="utf-8")
|
||||
fixture.config_path.parent.mkdir(parents=True)
|
||||
_symlink_or_skip(target, fixture.config_path)
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
fixture.install()
|
||||
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
assert fixture.config_path.is_symlink()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("runtime_name", ["config.json", "private.key", "guard.py", "hook.py"])
|
||||
def test_status_rejects_symbolic_link_runtime_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
runtime_name: str,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
runtime_path = fixture.agent_dir / runtime_name
|
||||
contents = runtime_path.read_bytes()
|
||||
target = fixture.home_dir / f"status-{runtime_name}.target"
|
||||
target.write_bytes(contents)
|
||||
runtime_path.unlink()
|
||||
_symlink_or_skip(target, runtime_path)
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
fixture.plugin.status()
|
||||
|
||||
|
||||
def test_uninstall_removes_entirely_managed_config_and_keeps_absence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
assert fixture.config_path.exists() is False
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
assert fixture.config_path.exists() is False
|
||||
user_source = '{"version":1,"hooks":{}}\n'
|
||||
fixture.config_path.write_text(user_source, encoding="utf-8")
|
||||
|
||||
source = '{"version":1,"hooks":{}}\n'
|
||||
fixture.config_path.write_text(source, encoding="utf-8")
|
||||
fixture.plugin.uninstall(AGENT_ID)
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == user_source
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == source
|
||||
|
||||
|
||||
def test_transaction_rolls_back_runtime_files_when_config_commit_fails(
|
||||
def test_runtime_files_have_private_modes_on_posix(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = _prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
existing_config={"version": 1, "hooks": {"sessionStart": []}},
|
||||
create_guard=False,
|
||||
)
|
||||
original = fixture.config_path.read_text(encoding="utf-8")
|
||||
real_replace = os.replace
|
||||
failed = False
|
||||
def fail_config_once(source: str, destination: str) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected config commit failure")
|
||||
real_replace(source, destination)
|
||||
monkeypatch.setattr(os, "replace", fail_config_once)
|
||||
with pytest.raises(OSError, match="injected config commit failure"):
|
||||
fixture.install()
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == original
|
||||
assert fixture.guard_path.exists() is False
|
||||
assert fixture.hook_path.exists() is False
|
||||
assert fixture.runtime_config_path.exists() is False
|
||||
assert fixture.private_key_path.exists() is False
|
||||
assert not list(fixture.home_dir.rglob("*.tmp"))
|
||||
assert not list(fixture.home_dir.rglob("*.rollback"))
|
||||
|
||||
|
||||
def test_atomic_config_write_detects_concurrent_source_change(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from elydora.plugins._transaction import write_changes
|
||||
from elydora.plugins.cursor_contract import render_document
|
||||
from elydora.plugins.cursor_io import read_document, rendered_change
|
||||
|
||||
fixture = _prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
existing_config={
|
||||
"version": 1,
|
||||
"hooks": {"sessionStart": [{"command": "original"}]},
|
||||
},
|
||||
)
|
||||
document = read_document()
|
||||
rendered = render_document(document, {})
|
||||
change = rendered_change(rendered)
|
||||
assert change is not None
|
||||
concurrent = '{"version":1,"hooks":{"sessionStart":[]}}\n'
|
||||
fixture.config_path.write_text(concurrent, encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="changed during installation"):
|
||||
write_changes([change], "Update Cursor hooks")
|
||||
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == concurrent
|
||||
assert not list(fixture.config_path.parent.glob("*.tmp"))
|
||||
if os.name == "nt":
|
||||
pytest.skip("POSIX mode contract")
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
assert fixture.guard_path.stat().st_mode & 0o777 == 0o700
|
||||
assert fixture.hook_path.stat().st_mode & 0o777 == 0o700
|
||||
assert fixture.runtime_config_path.stat().st_mode & 0o777 == 0o600
|
||||
assert fixture.private_key_path.stat().st_mode & 0o777 == 0o600
|
||||
@@ -0,0 +1,236 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
import pytest
|
||||
|
||||
from cursor_support import (
|
||||
ElydoraApiHandler,
|
||||
VALID_PRIVATE_KEY,
|
||||
managed_handler,
|
||||
prepare_fixture,
|
||||
run_handler,
|
||||
server_base_url,
|
||||
start_api_server,
|
||||
write_json,
|
||||
write_text,
|
||||
)
|
||||
from elydora.crypto import (
|
||||
compute_chain_hash,
|
||||
compute_payload_hash,
|
||||
jcs_canonicalize,
|
||||
)
|
||||
from elydora.utils import base64url_decode
|
||||
|
||||
|
||||
SUCCESS_EVENT = {
|
||||
"conversation_id": "conversation-1",
|
||||
"generation_id": "generation-1",
|
||||
"hook_event_name": "postToolUse",
|
||||
"tool_name": "Shell",
|
||||
"tool_input": {"command": "Get-ChildItem"},
|
||||
"tool_output": '{"exitCode":0,"stdout":"ok"}',
|
||||
"tool_use_id": "call-1",
|
||||
"cwd": "project",
|
||||
"duration": 42,
|
||||
}
|
||||
FAILURE_EVENT = {
|
||||
"conversation_id": "conversation-1",
|
||||
"generation_id": "generation-1",
|
||||
"hook_event_name": "postToolUseFailure",
|
||||
"tool_name": "Shell",
|
||||
"tool_input": {"command": "exit 1"},
|
||||
"tool_use_id": "call-2",
|
||||
"cwd": "project",
|
||||
"error_message": "command failed",
|
||||
"failure_type": "error",
|
||||
"duration": 21,
|
||||
"is_interrupt": False,
|
||||
}
|
||||
|
||||
|
||||
def _payload(value: object) -> bytes:
|
||||
return (json.dumps(value, separators=(",", ":")) + "\n").encode()
|
||||
|
||||
|
||||
def _assert_signed_operation(operation: dict, event: dict) -> None:
|
||||
assert operation["payload"] == event
|
||||
assert operation["payload_hash"] == compute_payload_hash(event)
|
||||
assert operation["subject"] == {"session_id": event["conversation_id"]}
|
||||
assert operation["action"] == {"tool": event["tool_name"]}
|
||||
expected_chain = compute_chain_hash(
|
||||
operation["prev_chain_hash"],
|
||||
operation["payload_hash"],
|
||||
operation["operation_id"],
|
||||
operation["issued_at"],
|
||||
)
|
||||
assert operation["chain_hash"] == expected_chain
|
||||
unsigned = {
|
||||
key: value
|
||||
for key, value in operation.items()
|
||||
if key not in {"chain_hash", "signature"}
|
||||
}
|
||||
public_key = Ed25519PrivateKey.from_private_bytes(
|
||||
base64url_decode(VALID_PRIVATE_KEY)
|
||||
).public_key()
|
||||
public_key.verify(
|
||||
base64url_decode(operation["signature"]),
|
||||
jcs_canonicalize(unsigned).encode(),
|
||||
)
|
||||
|
||||
|
||||
def test_handlers_enforce_status_and_submit_both_native_events(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
server = start_api_server()
|
||||
try:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.config["base_url"] = server_base_url(server)
|
||||
fixture.install()
|
||||
settings = fixture.settings()
|
||||
guard = managed_handler(settings, "preToolUse", "guard.py")
|
||||
success = managed_handler(settings, "postToolUse", "hook.py")
|
||||
failure = managed_handler(settings, "postToolUseFailure", "hook.py")
|
||||
|
||||
guard_result = run_handler(
|
||||
guard,
|
||||
_payload({"hook_event_name": "preToolUse"}),
|
||||
)
|
||||
success_result = run_handler(success, _payload(SUCCESS_EVENT))
|
||||
failure_result = run_handler(failure, _payload(FAILURE_EVENT))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
assert guard_result.returncode == 0
|
||||
assert json.loads(guard_result.stdout) == {"permission": "allow"}
|
||||
assert success_result.returncode == 0
|
||||
assert json.loads(success_result.stdout) == {}
|
||||
assert failure_result.returncode == 0
|
||||
assert json.loads(failure_result.stdout) == {}
|
||||
assert ElydoraApiHandler.get_paths == ["/v1/agents/agent-1"]
|
||||
assert ElydoraApiHandler.authorizations == [
|
||||
"Bearer token-1",
|
||||
"Bearer token-1",
|
||||
"Bearer token-1",
|
||||
]
|
||||
assert len(ElydoraApiHandler.operations) == 2
|
||||
_assert_signed_operation(ElydoraApiHandler.operations[0], SUCCESS_EVENT)
|
||||
_assert_signed_operation(ElydoraApiHandler.operations[1], FAILURE_EVENT)
|
||||
assert (
|
||||
ElydoraApiHandler.operations[1]["prev_chain_hash"]
|
||||
== ElydoraApiHandler.operations[0]["chain_hash"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["frozen", "revoked"])
|
||||
def test_guard_returns_native_denial_and_exit_two_from_cache(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
status: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
write_json(
|
||||
fixture.agent_dir / "status-cache.json",
|
||||
{"status": status, "cached_at": time.time()},
|
||||
)
|
||||
guard = managed_handler(fixture.settings(), "preToolUse", "guard.py")
|
||||
|
||||
result = run_handler(guard, _payload({"hook_event_name": "preToolUse"}))
|
||||
|
||||
denial = json.loads(result.stdout)
|
||||
assert result.returncode == 2
|
||||
assert denial["permission"] == "deny"
|
||||
assert status in denial["userMessage"]
|
||||
assert b"Tool execution blocked" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "source", "event"),
|
||||
[
|
||||
("config.json", "{ malformed", "preToolUse"),
|
||||
("config.json", "{ malformed", "postToolUse"),
|
||||
("private.key", "invalid", "postToolUse"),
|
||||
("chain-state.json", "{ malformed", "postToolUse"),
|
||||
(
|
||||
"status-cache.json",
|
||||
json.dumps({"status": "active", "cached_at": 4_102_444_800}),
|
||||
"preToolUse",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_integrity_failures_exit_one_and_are_observable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
target: str,
|
||||
source: str,
|
||||
event: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
write_text(fixture.agent_dir / target, source)
|
||||
script = "guard.py" if event == "preToolUse" else "hook.py"
|
||||
handler = managed_handler(fixture.settings(), event, script)
|
||||
payload = {"hook_event_name": event}
|
||||
|
||||
result = run_handler(handler, _payload(payload))
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stdout == b""
|
||||
assert b"Elydora" in result.stderr
|
||||
if event != "preToolUse":
|
||||
error_log = fixture.agent_dir / "error.log"
|
||||
assert error_log.is_file()
|
||||
assert error_log.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["preToolUse", "postToolUse"])
|
||||
def test_malformed_hook_input_exits_one(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
event: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
script = "guard.py" if event == "preToolUse" else "hook.py"
|
||||
handler = managed_handler(fixture.settings(), event, script)
|
||||
|
||||
result = run_handler(handler, b"{ malformed")
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stdout == b""
|
||||
assert b"invalid JSON" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["preToolUse", "postToolUse"])
|
||||
def test_api_failures_exit_one_and_audit_failure_is_logged(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
event: str,
|
||||
) -> None:
|
||||
server = start_api_server()
|
||||
ElydoraApiHandler.get_status = 500
|
||||
ElydoraApiHandler.post_status = 500
|
||||
try:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.config["base_url"] = server_base_url(server)
|
||||
fixture.install()
|
||||
script = "guard.py" if event == "preToolUse" else "hook.py"
|
||||
handler = managed_handler(fixture.settings(), event, script)
|
||||
payload = {"hook_event_name": event}
|
||||
result = run_handler(handler, _payload(payload))
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
assert result.returncode == 1
|
||||
assert result.stdout == b""
|
||||
assert b"HTTP 500" in result.stderr
|
||||
if event == "postToolUse":
|
||||
log = fixture.agent_dir / "error.log"
|
||||
assert "HTTP 500" in log.read_text(encoding="utf-8")
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from cursor_support import (
|
||||
AGENT_ID,
|
||||
prepare_fixture,
|
||||
symlink_or_skip,
|
||||
write_json,
|
||||
write_text,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"runtime_name",
|
||||
[
|
||||
"guard.py",
|
||||
"hook.py",
|
||||
"private.key",
|
||||
"chain-state.json",
|
||||
"status-cache.json",
|
||||
"error.log",
|
||||
],
|
||||
)
|
||||
def test_orphan_runtime_artifacts_are_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
runtime_name: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
artifact = fixture.agent_dir / runtime_name
|
||||
write_text(artifact, "orphan\n")
|
||||
|
||||
with pytest.raises(ValueError, match="identity cannot be verified"):
|
||||
fixture.install()
|
||||
|
||||
assert artifact.read_text(encoding="utf-8") == "orphan\n"
|
||||
assert fixture.config_path.exists() is False
|
||||
|
||||
|
||||
def test_mismatched_runtime_identity_is_rejected_before_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
write_json(
|
||||
fixture.runtime_config_path,
|
||||
{"agent_id": "another-agent", "agent_name": "cursor"},
|
||||
)
|
||||
original = fixture.runtime_config_path.read_text(encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="identity does not match"):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.runtime_config_path.read_text(encoding="utf-8") == original
|
||||
assert fixture.config_path.exists() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"runtime_name",
|
||||
[
|
||||
"config.json",
|
||||
"private.key",
|
||||
"guard.py",
|
||||
"hook.py",
|
||||
"chain-state.json",
|
||||
"status-cache.json",
|
||||
"error.log",
|
||||
],
|
||||
)
|
||||
def test_symbolic_link_runtime_is_rejected_before_config_writes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
runtime_name: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
target = fixture.home_dir / f"{runtime_name}.target"
|
||||
source = (
|
||||
json.dumps({"agent_id": AGENT_ID, "agent_name": "cursor"})
|
||||
if runtime_name == "config.json"
|
||||
else "target\n"
|
||||
)
|
||||
write_text(target, source)
|
||||
runtime_path = fixture.agent_dir / runtime_name
|
||||
symlink_or_skip(target, runtime_path)
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
fixture.install()
|
||||
|
||||
assert target.read_text(encoding="utf-8") == source
|
||||
assert fixture.config_path.exists() is False
|
||||
|
||||
|
||||
def test_symbolic_link_config_is_rejected_and_target_is_preserved(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
target = fixture.home_dir / "cursor-hooks.target.json"
|
||||
original = '{"version":1,"hooks":{}}\n'
|
||||
write_text(target, original)
|
||||
fixture.config_path.parent.mkdir(parents=True)
|
||||
symlink_or_skip(target, fixture.config_path)
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
fixture.install()
|
||||
|
||||
assert target.read_text(encoding="utf-8") == original
|
||||
assert fixture.config_path.is_symlink()
|
||||
|
||||
|
||||
def test_symbolic_link_config_directory_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
target = fixture.home_dir / "cursor-target"
|
||||
target.mkdir()
|
||||
symlink_or_skip(target, fixture.config_path.parent, directory=True)
|
||||
|
||||
with pytest.raises(OSError, match="not a physical directory"):
|
||||
fixture.install()
|
||||
|
||||
assert list(target.iterdir()) == []
|
||||
|
||||
|
||||
def test_symbolic_link_agent_directory_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.agent_dir.rmdir()
|
||||
target = fixture.home_dir / "agent-target"
|
||||
target.mkdir()
|
||||
symlink_or_skip(target, fixture.agent_dir, directory=True)
|
||||
|
||||
with pytest.raises(OSError, match="not a physical directory"):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.config_path.exists() is False
|
||||
assert list(target.iterdir()) == []
|
||||
|
||||
|
||||
def test_symbolic_link_runtime_root_is_rejected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.agent_dir.rmdir()
|
||||
fixture.agent_dir.parent.rmdir()
|
||||
target = fixture.home_dir / "runtime-target"
|
||||
(target / AGENT_ID).mkdir(parents=True)
|
||||
symlink_or_skip(target, fixture.agent_dir.parent, directory=True)
|
||||
|
||||
with pytest.raises(OSError, match="not a physical directory"):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.config_path.exists() is False
|
||||
assert list((target / AGENT_ID).iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"runtime_name",
|
||||
["config.json", "private.key", "guard.py", "hook.py"],
|
||||
)
|
||||
def test_status_rejects_symbolic_link_runtime_files(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
runtime_name: str,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(monkeypatch, tmp_path)
|
||||
fixture.install()
|
||||
runtime_path = fixture.agent_dir / runtime_name
|
||||
target = fixture.home_dir / f"status-{runtime_name}.target"
|
||||
target.write_bytes(runtime_path.read_bytes())
|
||||
runtime_path.unlink()
|
||||
symlink_or_skip(target, runtime_path)
|
||||
|
||||
with pytest.raises(OSError, match="physical file"):
|
||||
fixture.plugin.status()
|
||||
|
||||
|
||||
def test_transaction_rolls_back_all_files_when_config_commit_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
existing_config={"version": 1, "hooks": {"sessionStart": []}},
|
||||
)
|
||||
original = fixture.config_path.read_text(encoding="utf-8")
|
||||
real_replace = os.replace
|
||||
failed = False
|
||||
|
||||
def fail_config_once(source: str, destination: str) -> None:
|
||||
nonlocal failed
|
||||
if os.path.abspath(destination) == str(fixture.config_path) and not failed:
|
||||
failed = True
|
||||
raise OSError("injected config commit failure")
|
||||
real_replace(source, destination)
|
||||
|
||||
monkeypatch.setattr(os, "replace", fail_config_once)
|
||||
|
||||
with pytest.raises(OSError, match="injected config commit failure"):
|
||||
fixture.install()
|
||||
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == original
|
||||
for path in (
|
||||
fixture.guard_path,
|
||||
fixture.hook_path,
|
||||
fixture.runtime_config_path,
|
||||
fixture.private_key_path,
|
||||
):
|
||||
assert path.exists() is False
|
||||
assert not list(fixture.home_dir.rglob("*.tmp"))
|
||||
assert not list(fixture.home_dir.rglob("*.rollback"))
|
||||
|
||||
|
||||
def test_transaction_detects_concurrent_config_change(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
from elydora.plugins._transaction import write_changes
|
||||
from elydora.plugins.cursor_contract import render_document
|
||||
from elydora.plugins.cursor_io import read_document, rendered_change
|
||||
|
||||
fixture = prepare_fixture(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
existing_config={
|
||||
"version": 1,
|
||||
"hooks": {"sessionStart": [{"command": "original"}]},
|
||||
},
|
||||
)
|
||||
document = read_document()
|
||||
change = rendered_change(render_document(document, {}))
|
||||
assert change is not None
|
||||
concurrent = '{"version":1,"hooks":{"sessionStart":[]}}\n'
|
||||
fixture.config_path.write_text(concurrent, encoding="utf-8")
|
||||
|
||||
with pytest.raises(OSError, match="changed during installation"):
|
||||
write_changes([change], "Update Cursor hooks")
|
||||
|
||||
assert fixture.config_path.read_text(encoding="utf-8") == concurrent
|
||||
assert not list(fixture.config_path.parent.glob("*.tmp"))
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from threading import Thread
|
||||
import time
|
||||
|
||||
from elydora.plugins.hook_template import generate_guard_script
|
||||
|
||||
@@ -98,7 +99,7 @@ def test_cached_frozen_status_uses_blocking_exit_code(tmp_path: Path) -> None:
|
||||
script_path, server = create_guard(tmp_path, "active")
|
||||
cache_path = tmp_path / ".elydora" / AGENT_ID / "status-cache.json"
|
||||
cache_path.write_text(
|
||||
json.dumps({"status": "frozen", "cached_at": 4_102_444_800}),
|
||||
json.dumps({"status": "frozen", "cached_at": time.time()}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
try:
|
||||
|
||||
Reference in new issue
Block a user