fix: make Cursor hooks fail closed

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 01:16:22 -04:00
1 parent 6cb7ef188d
commit c3602da5f3
15 files changed
+1397 -718

No files matched your search

+198
View File
@@ -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}")
+4 -2
View File
@@ -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
View File
@@ -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
+236
View File
@@ -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")
+250
View File
@@ -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"))
+2 -1
View File
@@ -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: