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

+1 -1
View File
@@ -22,7 +22,7 @@ This repository owns the published `elydora` package, its CLI, synchronous and a
- Forward official hook JSON from STDIN without reshaping provider fields. - Forward official hook JSON from STDIN without reshaping provider fields.
- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard. - Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard.
- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist. - Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist.
- Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, use exact native command ownership, retain PowerShell exit code `2`, and set security-critical handlers to `failClosed` with a bounded timeout. - Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, and own exact native `preToolUse`, `postToolUse`, and `postToolUseFailure` commands. Forward native success and failure payloads, retain PowerShell exit code `2`, set every handler to `failClosed` with a bounded timeout, and commit the guard, audit script, runtime identity, private key, and hook configuration as one rollback-capable transaction.
- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance.
- Select Kimi Code and legacy kimi-cli config targets from existing runtime evidence; a fresh installation targets current Kimi Code and keeps migration markers isolated to active runtimes. - Select Kimi Code and legacy kimi-cli config targets from existing runtime evidence; a fresh installation targets current Kimi Code and keeps migration markers isolated to active runtimes.
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources.
+1 -1
View File
@@ -80,7 +80,7 @@ Credential options may be omitted in an interactive terminal; the CLI then reads
Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions.
Cursor installation writes native global `preToolUse` and `postToolUse` handlers to `~/.cursor/hooks.json`. Elydora preserves unrelated user hooks, leaves project and enterprise sources unchanged, and configures a 10-second fail-closed command boundary with exact freeze exit-code propagation. Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora forwards Cursor's native success and failure payloads, preserves unrelated user hooks, and leaves project and enterprise sources unchanged. The generated guard and audit handlers use a 10-second fail-closed boundary, exact freeze exit-code propagation, protected runtime credentials, and one rollback-capable transaction for all runtime and hook configuration files.
Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control.
+59 -2
View File
@@ -2,11 +2,14 @@
from __future__ import annotations from __future__ import annotations
import base64
import json import json
import os import os
import urllib.parse
from typing import List, Optional from typing import List, Optional
from elydora._runtime_paths import resolve_agent_directory from elydora._runtime_paths import resolve_agent_directory
from elydora.utils import base64url_encode
from ._transaction import FileChange from ._transaction import FileChange
from .base import AgentPlugin, InstallConfig, PluginStatus from .base import AgentPlugin, InstallConfig, PluginStatus
@@ -29,6 +32,7 @@ from .cursor_io import (
require_runtime_directory, require_runtime_directory,
runtime_change, runtime_change,
runtime_files_exist, runtime_files_exist,
validate_config_directory,
validate_runtime_identity, validate_runtime_identity,
write_cursor_changes, write_cursor_changes,
) )
@@ -72,28 +76,73 @@ def _agent_paths(config: InstallConfig) -> tuple[str, str, str]:
return agent_id, agent_directory, guard_path return agent_id, agent_directory, guard_path
def _validate_install_config(config: InstallConfig) -> None:
for field in ("org_id", "agent_id", "kid", "private_key"):
value = config.get(field)
if not isinstance(value, str) or not value:
raise ValueError(f"{field} is required")
if config.get("agent_name") != AGENT_KEY:
raise ValueError(f"Cursor installation requires agent_name {AGENT_KEY}")
private_key = config["private_key"]
try:
padded = private_key + "=" * ((4 - len(private_key) % 4) % 4)
seed = base64.b64decode(
padded.replace("-", "+").replace("_", "/"),
validate=True,
)
except (ValueError, UnicodeEncodeError) as error:
raise ValueError(
"private_key must be a canonical 32-byte base64url value"
) from error
if len(seed) != 32 or base64url_encode(seed) != private_key:
raise ValueError("private_key must be a canonical 32-byte base64url value")
base_url = config.get("base_url", "https://api.elydora.com")
if not isinstance(base_url, str):
raise ValueError("base_url must be a string")
parsed = urllib.parse.urlsplit(base_url)
if parsed.scheme not in ("http", "https") or not parsed.netloc:
raise ValueError("base_url must be an absolute HTTP or HTTPS URL")
token = config.get("token", "")
if not isinstance(token, str):
raise ValueError("token must be a string")
class CursorPlugin(AgentPlugin): class CursorPlugin(AgentPlugin):
"""Install Elydora into Cursor's native global user hooks.""" """Install Elydora into Cursor's native global user hooks."""
manages_guard_runtime = True manages_guard_runtime = True
def preflight_install(self, config: InstallConfig) -> None: def preflight_install(self, config: InstallConfig) -> None:
_validate_install_config(config)
read_document() read_document()
validate_config_directory()
agent_id, agent_directory, guard_path = _agent_paths(config) agent_id, agent_directory, guard_path = _agent_paths(config)
if not physical_directory_exists(runtime_root()):
return
if not physical_directory_exists(agent_directory): if not physical_directory_exists(agent_directory):
return return
validate_runtime_identity( identity_exists = validate_runtime_identity(
os.path.join(agent_directory, "config.json"), os.path.join(agent_directory, "config.json"),
agent_id, agent_id,
) )
runtime_exists = False
for file_path, label in ( for file_path, label in (
(guard_path, "Elydora guard runtime"), (guard_path, "Elydora guard runtime"),
(os.path.join(agent_directory, AUDIT_SCRIPT), "Elydora audit runtime"), (os.path.join(agent_directory, AUDIT_SCRIPT), "Elydora audit runtime"),
(os.path.join(agent_directory, "private.key"), "Elydora private key"), (os.path.join(agent_directory, "private.key"), "Elydora private key"),
(os.path.join(agent_directory, "chain-state.json"), "Elydora chain state"),
(os.path.join(agent_directory, "status-cache.json"), "Elydora status cache"),
(os.path.join(agent_directory, "error.log"), "Elydora error log"),
): ):
physical_file_exists(file_path, label) runtime_exists = physical_file_exists(file_path, label) or runtime_exists
if runtime_exists and not identity_exists:
raise ValueError(
"Elydora runtime identity cannot be verified without config.json: "
f"{agent_directory}"
)
def install(self, config: InstallConfig) -> None: def install(self, config: InstallConfig) -> None:
self.preflight_install(config)
document = read_document() document = read_document()
agent_id, agent_directory, guard_path = _agent_paths(config) agent_id, agent_directory, guard_path = _agent_paths(config)
require_runtime_directory(agent_directory) require_runtime_directory(agent_directory)
@@ -110,17 +159,25 @@ class CursorPlugin(AgentPlugin):
*hooks.get("postToolUse", []), *hooks.get("postToolUse", []),
build_handler(audit_path), build_handler(audit_path),
] ]
hooks["postToolUseFailure"] = [
*hooks.get("postToolUseFailure", []),
build_handler(audit_path),
]
hook_script = generate_hook_script( hook_script = generate_hook_script(
org_id=config.get("org_id", ""), org_id=config.get("org_id", ""),
agent_id=agent_id, agent_id=agent_id,
kid=config.get("kid", ""), kid=config.get("kid", ""),
base_url=config.get("base_url", "https://api.elydora.com"), base_url=config.get("base_url", "https://api.elydora.com"),
success_output="{}\n", success_output="{}\n",
fail_closed=True,
native_payload=True,
agent_name=AGENT_KEY,
) )
guard_script = generate_guard_script( guard_script = generate_guard_script(
AGENT_KEY, AGENT_KEY,
agent_id, agent_id,
success_output='{"permission":"allow"}\n', success_output='{"permission":"allow"}\n',
fail_closed=True,
) )
changes = _present([ changes = _present([
runtime_change( runtime_change(
+5 -1
View File
@@ -240,6 +240,7 @@ def _contains_managed_hook(hooks: CursorHooks) -> bool:
for event, script_name in ( for event, script_name in (
("preToolUse", GUARD_SCRIPT), ("preToolUse", GUARD_SCRIPT),
("postToolUse", AUDIT_SCRIPT), ("postToolUse", AUDIT_SCRIPT),
("postToolUseFailure", AUDIT_SCRIPT),
) )
for handler in hooks.get(event, []) for handler in hooks.get(event, [])
) )
@@ -269,6 +270,7 @@ def remove_managed_hooks(
for event, script_name in ( for event, script_name in (
("preToolUse", GUARD_SCRIPT), ("preToolUse", GUARD_SCRIPT),
("postToolUse", AUDIT_SCRIPT), ("postToolUse", AUDIT_SCRIPT),
("postToolUseFailure", AUDIT_SCRIPT),
): ):
handlers = [] handlers = []
for handler in result.get(event, []): for handler in result.get(event, []):
@@ -295,6 +297,7 @@ def _entirely_managed(document: CursorDocument) -> bool:
script_name = { script_name = {
"preToolUse": GUARD_SCRIPT, "preToolUse": GUARD_SCRIPT,
"postToolUse": AUDIT_SCRIPT, "postToolUse": AUDIT_SCRIPT,
"postToolUseFailure": AUDIT_SCRIPT,
}.get(event) }.get(event)
if script_name is None or not handlers: if script_name is None or not handlers:
return False return False
@@ -335,9 +338,10 @@ def _managed_ids(handlers: List[JsonObject], script_name: str) -> Dict[str, str]
def runtime_contracts(hooks: CursorHooks) -> List[RuntimeContract]: def runtime_contracts(hooks: CursorHooks) -> List[RuntimeContract]:
guards = _managed_ids(hooks.get("preToolUse", []), GUARD_SCRIPT) guards = _managed_ids(hooks.get("preToolUse", []), GUARD_SCRIPT)
audits = _managed_ids(hooks.get("postToolUse", []), AUDIT_SCRIPT) audits = _managed_ids(hooks.get("postToolUse", []), AUDIT_SCRIPT)
failures = _managed_ids(hooks.get("postToolUseFailure", []), AUDIT_SCRIPT)
contracts = [] contracts = []
for key, agent_id in guards.items(): for key, agent_id in guards.items():
if key not in audits: if key not in audits or key not in failures:
continue continue
agent_directory = os.path.join(runtime_root(), agent_id) agent_directory = os.path.join(runtime_root(), agent_id)
contracts.append(RuntimeContract( contracts.append(RuntimeContract(
+15 -2
View File
@@ -48,6 +48,18 @@ def read_document() -> CursorDocument:
return create_document(file_path) if raw is None else parse_document(file_path, raw) return create_document(file_path) if raw is None else parse_document(file_path, raw)
def validate_config_directory() -> None:
directory = os.path.dirname(config_path())
try:
metadata = os.lstat(directory)
except FileNotFoundError:
return
except OSError as error:
raise OSError(f"Inspect Cursor hooks directory at {directory}: {error}") from error
if not stat.S_ISDIR(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
raise OSError(f"Cursor hooks directory is not a physical directory: {directory}")
def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]: def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]:
if not rendered.changed: if not rendered.changed:
return None return None
@@ -122,10 +134,10 @@ def _read_runtime_config(file_path: str) -> Optional[JsonObject]:
def validate_runtime_identity( def validate_runtime_identity(
file_path: str, file_path: str,
agent_id: str, agent_id: str,
) -> None: ) -> bool:
config = _read_runtime_config(file_path) config = _read_runtime_config(file_path)
if config is None: if config is None:
return return False
if config.get("agent_name") != AGENT_KEY or not _same_agent_id( if config.get("agent_name") != AGENT_KEY or not _same_agent_id(
config.get("agent_id"), agent_id config.get("agent_id"), agent_id
): ):
@@ -133,6 +145,7 @@ def validate_runtime_identity(
f"Elydora runtime config identity does not match Cursor agent {agent_id}: " f"Elydora runtime config identity does not match Cursor agent {agent_id}: "
f"{file_path}" f"{file_path}"
) )
return True
def _same_agent_id(value: Any, expected: str) -> bool: def _same_agent_id(value: Any, expected: str) -> bool:
+216
View File
@@ -0,0 +1,216 @@
"""Generate self-contained agent status guards."""
from __future__ import annotations
import sys
from .runtime_reader_template import PROTECTED_RUNTIME_READER
def generate_guard_script(
agent_name: str,
agent_id: str,
success_output: str = "",
*,
fail_closed: bool = False,
) -> str:
"""Return a self-contained status guard for a command-hook provider."""
return f'''#!{sys.executable}
"""Elydora agent guard for {agent_name}. Generated by elydora-sdk."""
import json
import math
import os
import stat
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
AGENT_NAME = {agent_name!r}
AGENT_ID = {agent_id!r}
SUCCESS_OUTPUT = {success_output!r}
FAIL_CLOSED = {fail_closed!r}
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
CONFIG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "config.json")
STATUS_CACHE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "status-cache.json")
CACHE_TTL_SECONDS = 60
{PROTECTED_RUNTIME_READER}
def validate_status(value, source):
if value not in ("active", "frozen", "revoked"):
raise ValueError(source + " contains an invalid agent status")
return value
def block_agent(status):
state = "frozen" if status == "frozen" else "revoked"
message = 'Agent "' + AGENT_NAME + '" is ' + state + " in Elydora."
if SUCCESS_OUTPUT:
sys.stdout.write(json.dumps({{
"permission": "deny",
"userMessage": message,
"agentMessage": message + " Tool execution is blocked.",
}}) + "\\n")
sys.stdout.flush()
sys.stderr.write("[Elydora guard] " + message + " Tool execution blocked.\\n")
raise SystemExit(2)
def read_input():
raw = sys.stdin.read()
if not raw.strip():
if FAIL_CLOSED:
raise ValueError("Hook input is empty")
return
try:
value = json.loads(raw)
except (json.JSONDecodeError, ValueError) as error:
if FAIL_CLOSED:
raise ValueError("Hook input is invalid JSON: " + str(error)) from error
return
if FAIL_CLOSED and not isinstance(value, dict):
raise ValueError("Hook input must contain a JSON object")
def read_config():
try:
raw = read_protected_file(
CONFIG_PATH,
"Agent config",
MAX_PROTECTED_CONFIG_BYTES,
).decode("utf-8")
config = json.loads(raw)
except Exception as error:
raise RuntimeError("Failed to read agent config: " + str(error)) from error
if not isinstance(config, dict):
raise ValueError("Agent config must contain a JSON object")
if config.get("agent_id") != AGENT_ID:
raise ValueError("Agent config identity does not match the generated guard")
configured_name = config.get("agent_name")
if configured_name is not None and configured_name != AGENT_NAME:
raise ValueError("Agent config identity does not match the generated guard")
return config
def read_status_cache():
try:
raw = read_protected_file(
STATUS_CACHE_PATH,
"Status cache",
MAX_PROTECTED_CONFIG_BYTES,
require_private_permissions=False,
).decode("utf-8")
except FileNotFoundError:
return None
try:
cache = json.loads(raw)
except (json.JSONDecodeError, ValueError) as error:
raise ValueError("Status cache is invalid JSON: " + str(error)) from error
if not isinstance(cache, dict):
raise ValueError("Status cache must contain a JSON object")
cached_at = cache.get("cached_at")
if isinstance(cached_at, bool) or not isinstance(cached_at, (int, float)):
raise ValueError("Status cache has an invalid timestamp")
if not math.isfinite(cached_at):
raise ValueError("Status cache has an invalid timestamp")
age = time.time() - cached_at
if age < 0:
raise ValueError("Status cache timestamp is in the future")
return validate_status(cache.get("status"), "Status cache"), age
def write_status_cache(status):
token = os.urandom(8).hex()
temporary_path = STATUS_CACHE_PATH + "." + str(os.getpid()) + "." + token + ".tmp"
descriptor = -1
try:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
descriptor = os.open(temporary_path, flags, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as file:
descriptor = -1
json.dump({{"status": status, "cached_at": time.time()}}, file)
file.flush()
os.fsync(file.fileno())
os.replace(temporary_path, STATUS_CACHE_PATH)
finally:
if descriptor >= 0:
os.close(descriptor)
try:
os.remove(temporary_path)
except FileNotFoundError:
pass
def resolve_remote_status(config):
base_url = config.get("base_url", "https://api.elydora.com")
if not isinstance(base_url, str):
raise ValueError("Agent config base_url must be a string")
parsed = urllib.parse.urlsplit(base_url)
if parsed.scheme not in ("http", "https"):
raise ValueError("Agent config base_url must use HTTP or HTTPS")
token = config.get("token", "")
if not isinstance(token, str):
raise ValueError("Agent config token must be a string")
url = base_url.rstrip("/") + "/v1/agents/" + urllib.parse.quote(AGENT_ID, safe="")
request = urllib.request.Request(url, method="GET")
request.add_header("Accept", "application/json")
if token:
request.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(request, timeout=3) as response:
value = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise RuntimeError(
"Agent status API returned HTTP " + str(error.code)
) from error
status = value.get("agent", {{}}).get("status") if isinstance(value, dict) else None
return validate_status(status, "Agent status API")
def main():
read_input()
try:
config = read_config()
except Exception as error:
if FAIL_CLOSED:
raise
sys.stderr.write("[Elydora guard] " + str(error) + "\\n")
return
try:
cache = read_status_cache()
if cache is not None and cache[1] < CACHE_TTL_SECONDS:
if cache[0] != "active":
block_agent(cache[0])
return
except Exception as error:
if FAIL_CLOSED:
raise
sys.stderr.write("[Elydora guard] " + str(error) + "\\n")
try:
status = resolve_remote_status(config)
write_status_cache(status)
if status != "active":
block_agent(status)
except SystemExit:
raise
except Exception as error:
if FAIL_CLOSED:
raise RuntimeError("Failed to resolve agent status: " + str(error)) from error
sys.stderr.write("[Elydora guard] Failed to resolve agent status: " + str(error) + "\\n")
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error:
sys.stderr.write("[Elydora guard] " + repr(error) + "\\n")
raise SystemExit(1)
if SUCCESS_OUTPUT:
sys.stdout.write(SUCCESS_OUTPUT)
'''
+250 -347
View File
@@ -1,14 +1,14 @@
"""Generates self-contained Python hook scripts for AI agent integrations. """Generate self-contained audit hook scripts."""
The generated script contains all crypto and networking inline so it has zero
import dependencies beyond the Python stdlib + cryptography (already an
Elydora SDK dependency).
"""
from __future__ import annotations from __future__ import annotations
import sys import sys
from .guard_template import generate_guard_script
from .runtime_reader_template import PROTECTED_RUNTIME_READER
__all__ = ["generate_guard_script", "generate_hook_script"]
def generate_hook_script( def generate_hook_script(
*, *,
@@ -17,76 +17,63 @@ def generate_hook_script(
kid: str, kid: str,
base_url: str, base_url: str,
success_output: str = "", success_output: str = "",
fail_closed: bool = False,
native_payload: bool = False,
agent_name: str = "",
) -> str: ) -> str:
"""Return a complete, self-contained Python hook script as a string. """Return a self-contained signed audit hook."""
return f'''#!{sys.executable}
The script: """Elydora audit hook. Generated by elydora-sdk."""
1. Reads JSON from stdin (tool-use event)
2. Constructs an Elydora Operation Record (EOR)
3. Signs it with Ed25519
4. POSTs it fire-and-forget to the Elydora API
5. Always exits 0 (never blocks the host agent)
Chain state is persisted in ~/.elydora/{agent_id}/chain-state.json.
"""
shebang = sys.executable
return f'''#!{shebang}
"""Elydora audit hook — auto-generated, do not edit."""
import base64 import base64
import hashlib import hashlib
import json import json
import math import math
import os import os
import re
import stat import stat
import struct import struct
import sys import sys
import time import time
import urllib.error
import urllib.parse
import urllib.request
# ---------------------------------------------------------------------------
# Configuration (baked at install time)
# ---------------------------------------------------------------------------
ORG_ID = {org_id!r} ORG_ID = {org_id!r}
AGENT_ID = {agent_id!r} AGENT_ID = {agent_id!r}
AGENT_NAME = {agent_name!r}
KID = {kid!r} KID = {kid!r}
BASE_URL = {base_url!r} BASE_URL = {base_url!r}
SUCCESS_OUTPUT = {success_output!r} SUCCESS_OUTPUT = {success_output!r}
FAIL_CLOSED = {fail_closed!r}
NATIVE_PAYLOAD = {native_payload!r}
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora") ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
CONFIG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "config.json")
PRIVATE_KEY_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "private.key") PRIVATE_KEY_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "private.key")
CHAIN_STATE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "chain-state.json") CHAIN_STATE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "chain-state.json")
ERROR_LOG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "error.log") ERROR_LOG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "error.log")
ZERO_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" ZERO_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
MAX_PRIVATE_KEY_BYTES = 64 * 1024
# --------------------------------------------------------------------------- {PROTECTED_RUNTIME_READER}
# Base64url helpers
# ---------------------------------------------------------------------------
def base64url_encode(data: bytes) -> str:
def base64url_encode(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def base64url_decode(s: str) -> bytes:
s = s.replace("-", "+").replace("_", "/")
padding = 4 - len(s) % 4
if padding != 4:
s += "=" * padding
return base64.b64decode(s)
# --------------------------------------------------------------------------- def base64url_decode(value):
# SHA-256 encoded = value.replace("-", "+").replace("_", "/")
# --------------------------------------------------------------------------- encoded += "=" * ((4 - len(encoded) % 4) % 4)
return base64.b64decode(encoded, validate=True)
def sha256_base64url(data): def sha256_base64url(data):
if isinstance(data, str): if isinstance(data, str):
data = data.encode("utf-8") data = data.encode("utf-8")
return base64url_encode(hashlib.sha256(data).digest()) return base64url_encode(hashlib.sha256(data).digest())
# ---------------------------------------------------------------------------
# JCS canonicalization (RFC 8785)
# ---------------------------------------------------------------------------
def _jcs_number(value): def jcs_number(value):
if isinstance(value, bool): if isinstance(value, bool):
return "true" if value else "false" return "true" if value else "false"
if isinstance(value, int): if isinstance(value, int):
@@ -97,362 +84,278 @@ def _jcs_number(value):
return "0" return "0"
return json.dumps(value) return json.dumps(value)
def jcs_canonicalize(value): def jcs_canonicalize(value):
if value is None: if value is None:
return "null" return "null"
if isinstance(value, bool): if isinstance(value, bool):
return "true" if value else "false" return "true" if value else "false"
if isinstance(value, (int, float)): if isinstance(value, (int, float)):
return _jcs_number(value) return jcs_number(value)
if isinstance(value, str): if isinstance(value, str):
return json.dumps(value, ensure_ascii=False) return json.dumps(value, ensure_ascii=False)
if isinstance(value, list): if isinstance(value, list):
return "[" + ",".join(jcs_canonicalize(v) for v in value) + "]" return "[" + ",".join(jcs_canonicalize(item) for item in value) + "]"
if isinstance(value, dict): if isinstance(value, dict):
keys = sorted(value.keys()) pairs = [
pairs = [] json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(value[key])
for k in keys: for key in sorted(value)
pairs.append(json.dumps(k, ensure_ascii=False) + ":" + jcs_canonicalize(value[k])) ]
return "{{" + ",".join(pairs) + "}}" return "{{" + ",".join(pairs) + "}}"
return json.dumps(value) return json.dumps(value)
# ---------------------------------------------------------------------------
# Hash helpers
# ---------------------------------------------------------------------------
def compute_payload_hash(payload): def compute_payload_hash(payload):
return sha256_base64url(jcs_canonicalize(payload)) return sha256_base64url(jcs_canonicalize(payload))
def compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at):
input_str = f"{{prev_chain_hash}}|{{payload_hash}}|{{operation_id}}|{{issued_at}}"
return sha256_base64url(input_str)
# --------------------------------------------------------------------------- def compute_chain_hash(previous, payload_hash, operation_id, issued_at):
# Ed25519 signing (uses cryptography library) value = f"{{previous}}|{{payload_hash}}|{{operation_id}}|{{issued_at}}"
# --------------------------------------------------------------------------- return sha256_base64url(value)
def sign_ed25519(private_key_b64url, data):
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
seed = base64url_decode(private_key_b64url)
key = Ed25519PrivateKey.from_private_bytes(seed)
return base64url_encode(key.sign(data))
def read_private_key(): def read_private_key():
before = os.lstat(PRIVATE_KEY_PATH) encoded = read_protected_file(PRIVATE_KEY_PATH, "Private key").decode("utf-8")
if not stat.S_ISREG(before.st_mode): if not encoded or "\\r" in encoded or "\\n" in encoded or "\\0" in encoded:
raise ValueError("Private key path is not a regular file: " + PRIVATE_KEY_PATH) raise ValueError("Private key file must contain exactly one line")
if before.st_size > MAX_PRIVATE_KEY_BYTES: seed = base64url_decode(encoded)
raise ValueError("Private key file exceeds the size limit: " + PRIVATE_KEY_PATH) if len(seed) != 32 or base64url_encode(seed) != encoded:
if os.name != "nt" and before.st_mode & (stat.S_IRWXG | stat.S_IRWXO): raise ValueError("Private key must be a canonical 32-byte base64url value")
raise PermissionError("Private key file permissions are too broad: " + PRIVATE_KEY_PATH) return seed
flags = os.O_RDONLY
flags |= getattr(os, "O_BINARY", 0) def sign_ed25519(seed, data):
flags |= getattr(os, "O_CLOEXEC", 0) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
flags |= getattr(os, "O_NOFOLLOW", 0) return base64url_encode(Ed25519PrivateKey.from_private_bytes(seed).sign(data))
descriptor = os.open(PRIVATE_KEY_PATH, flags)
def generate_uuidv7():
timestamp = struct.pack(">Q", int(time.time() * 1000))[2:]
random_a = (struct.unpack(">H", os.urandom(2))[0] & 0x0FFF) | 0x7000
random_b = bytearray(os.urandom(8))
random_b[0] = (random_b[0] & 0x3F) | 0x80
value = (timestamp + struct.pack(">H", random_a) + bytes(random_b)).hex()
return f"{{value[0:8]}}-{{value[8:12]}}-{{value[12:16]}}-{{value[16:20]}}-{{value[20:32]}}"
def log_error(error):
descriptor = -1
try: try:
file = os.fdopen(descriptor, "rb") message = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
descriptor = -1 message += " [elydora-hook] " + repr(error) + "\\n"
with file: flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
after = os.fstat(file.fileno()) flags |= getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
if not stat.S_ISREG(after.st_mode): flags |= getattr(os, "O_NOFOLLOW", 0)
raise ValueError("Private key path is not a regular file: " + PRIVATE_KEY_PATH) descriptor = os.open(ERROR_LOG_PATH, flags, 0o600)
if after.st_size > MAX_PRIVATE_KEY_BYTES: metadata = os.fstat(descriptor)
raise ValueError("Private key file exceeds the size limit: " + PRIVATE_KEY_PATH) if not stat.S_ISREG(metadata.st_mode):
if os.name != "nt" and after.st_mode & (stat.S_IRWXG | stat.S_IRWXO): raise ValueError("Error log path is not a physical file: " + ERROR_LOG_PATH)
raise PermissionError("Private key file permissions are too broad: " + PRIVATE_KEY_PATH) with os.fdopen(descriptor, "a", encoding="utf-8") as file:
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): descriptor = -1
raise OSError("Private key file changed while opening: " + PRIVATE_KEY_PATH) file.write(message)
encoded = file.read(MAX_PRIVATE_KEY_BYTES + 1) if os.name != "nt":
os.chmod(ERROR_LOG_PATH, 0o600)
except Exception as log_failure:
sys.stderr.write("[Elydora audit] Failed to write error log: " + str(log_failure) + "\\n")
finally: finally:
if descriptor >= 0: if descriptor >= 0:
os.close(descriptor) os.close(descriptor)
if len(encoded) > MAX_PRIVATE_KEY_BYTES:
raise ValueError("Private key file exceeds the size limit: " + PRIVATE_KEY_PATH)
value = encoded.decode("utf-8")
if not value or "\\r" in value or "\\n" in value or "\\0" in value:
raise ValueError("Private key file must contain exactly one line")
return value
# ---------------------------------------------------------------------------
# UUIDv7
# ---------------------------------------------------------------------------
def generate_uuidv7():
timestamp_ms = int(time.time() * 1000)
ts_bytes = struct.pack(">Q", timestamp_ms)[2:]
rand_a = struct.unpack(">H", os.urandom(2))[0]
rand_a = (rand_a & 0x0FFF) | 0x7000
rand_b = bytearray(os.urandom(8))
rand_b[0] = (rand_b[0] & 0x3F) | 0x80
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
h = uuid_bytes.hex()
return f"{{h[0:8]}}-{{h[8:12]}}-{{h[12:16]}}-{{h[16:20]}}-{{h[20:32]}}"
def generate_nonce():
return base64url_encode(os.urandom(16))
# ---------------------------------------------------------------------------
# Chain state persistence
# ---------------------------------------------------------------------------
def read_chain_state(): def read_chain_state():
try: try:
with open(CHAIN_STATE_PATH, "r") as f: raw = read_protected_file(
state = json.load(f) CHAIN_STATE_PATH,
return state.get("prev_chain_hash", ZERO_CHAIN_HASH) or ZERO_CHAIN_HASH "Chain state",
except (FileNotFoundError, json.JSONDecodeError): MAX_PROTECTED_CONFIG_BYTES,
require_private_permissions=False,
).decode("utf-8")
except FileNotFoundError:
return ZERO_CHAIN_HASH return ZERO_CHAIN_HASH
except Exception as error:
if FAIL_CLOSED:
raise RuntimeError("Failed to read chain state: " + str(error)) from error
log_error(error)
return ZERO_CHAIN_HASH
try:
state = json.loads(raw)
except (json.JSONDecodeError, ValueError) as error:
if FAIL_CLOSED:
raise RuntimeError("Chain state is invalid JSON: " + str(error)) from error
log_error(error)
return ZERO_CHAIN_HASH
previous = state.get("prev_chain_hash") if isinstance(state, dict) else None
if not isinstance(previous, str) or re.fullmatch(r"[A-Za-z0-9_-]{{43}}", previous) is None:
error = ValueError("Chain state contains an invalid prev_chain_hash")
if FAIL_CLOSED:
raise error
log_error(error)
return ZERO_CHAIN_HASH
return previous
def write_chain_state(chain_hash): def write_chain_state(chain_hash):
os.makedirs(os.path.dirname(CHAIN_STATE_PATH), exist_ok=True) token = os.urandom(8).hex()
tmp_path = CHAIN_STATE_PATH + ".tmp" temporary_path = CHAIN_STATE_PATH + "." + str(os.getpid()) + "." + token + ".tmp"
with open(tmp_path, "w") as f: descriptor = -1
json.dump({{"prev_chain_hash": chain_hash}}, f)
try: try:
os.replace(tmp_path, CHAIN_STATE_PATH) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
except OSError: descriptor = os.open(temporary_path, flags, 0o600)
import shutil with os.fdopen(descriptor, "w", encoding="utf-8") as file:
shutil.move(tmp_path, CHAIN_STATE_PATH) descriptor = -1
json.dump({{"prev_chain_hash": chain_hash}}, file)
def log_error(err): file.flush()
try: os.fsync(file.fileno())
ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) os.replace(temporary_path, CHAIN_STATE_PATH)
msg = f"{{ts}} [elydora-hook] {{err}}\\n" finally:
os.makedirs(os.path.dirname(ERROR_LOG_PATH), exist_ok=True) if descriptor >= 0:
with open(ERROR_LOG_PATH, "a") as f: os.close(descriptor)
f.write(msg)
except Exception:
pass
# ---------------------------------------------------------------------------
# Main hook logic
# ---------------------------------------------------------------------------
def main():
try:
raw = sys.stdin.read()
if not raw.strip():
return
try: try:
event = json.loads(raw) os.remove(temporary_path)
except (json.JSONDecodeError, ValueError): except FileNotFoundError:
return pass
# Extract tool event data
tool_name = "unknown"
tool_input = {{}}
session_id = "unknown"
if isinstance(event, dict): def read_runtime_config():
cline_event = event.get("tool_result") if not FAIL_CLOSED:
if not isinstance(cline_event, dict): return {{
cline_event = event.get("tool_call")
if not isinstance(cline_event, dict):
cline_event = {{}}
tool_name = event.get(
"tool_name",
event.get("toolName", event.get("name", cline_event.get("name", "unknown"))),
)
tool_input = event.get(
"tool_input",
event.get("toolInput", event.get("input", cline_event.get("input", {{}}))),
)
session_id = event.get(
"session_id",
event.get("sessionId", event.get("session", event.get("taskId", "unknown"))),
)
# Read chain state
prev_chain_hash = read_chain_state()
# Build the EOR
operation_id = generate_uuidv7()
issued_at = int(time.time() * 1000)
nonce = generate_nonce()
payload = {{
"tool_name": tool_name,
"tool_input": tool_input,
"session_id": session_id,
}}
payload_hash = compute_payload_hash(payload)
chain_hash = compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at)
eor_without_sig = {{
"op_version": "1.0",
"operation_id": operation_id,
"org_id": ORG_ID, "org_id": ORG_ID,
"agent_id": AGENT_ID, "agent_id": AGENT_ID,
"issued_at": issued_at, "agent_name": AGENT_NAME,
"ttl_ms": 30000, "kid": KID,
"nonce": nonce, "base_url": BASE_URL,
"operation_type": "ai.tool_use", "token": "",
"subject": {{"session_id": session_id}},
"action": {{"tool": tool_name}},
"payload": payload,
"payload_hash": payload_hash,
"prev_chain_hash": prev_chain_hash,
"agent_pubkey_kid": KID,
}} }}
try:
raw = read_protected_file(
CONFIG_PATH,
"Agent config",
MAX_PROTECTED_CONFIG_BYTES,
).decode("utf-8")
config = json.loads(raw)
except Exception as error:
raise RuntimeError("Failed to read agent config: " + str(error)) from error
if not isinstance(config, dict):
raise ValueError("Agent config must contain a JSON object")
if config.get("agent_id") != AGENT_ID or config.get("agent_name") != AGENT_NAME:
raise ValueError("Agent config identity does not match the generated audit hook")
for field in ("org_id", "agent_id", "kid"):
if not isinstance(config.get(field), str) or not config[field]:
raise ValueError("Agent config is missing " + field)
base_url = config.get("base_url", "https://api.elydora.com")
if not isinstance(base_url, str):
raise ValueError("Agent config base_url must be a string")
if urllib.parse.urlsplit(base_url).scheme not in ("http", "https"):
raise ValueError("Agent config base_url must use HTTP or HTTPS")
if not isinstance(config.get("token", ""), str):
raise ValueError("Agent config token must be a string")
return config
canonical = jcs_canonicalize(eor_without_sig)
signature = sign_ed25519(read_private_key(), canonical.encode("utf-8"))
eor = dict(eor_without_sig) def read_event():
eor["chain_hash"] = chain_hash raw = sys.stdin.read()
eor["signature"] = signature if not raw.strip():
raise ValueError("Hook input is empty")
try:
event = json.loads(raw)
except (json.JSONDecodeError, ValueError) as error:
raise ValueError("Hook input is invalid JSON: " + str(error)) from error
if not isinstance(event, dict):
raise ValueError("Hook input must contain a JSON object")
return event
# POST to /v1/operations (5s timeout)
# Only update local chain state on success to prevent desync def submit_operation(event, runtime):
try: nested = event.get("tool_result")
import urllib.request if not isinstance(nested, dict):
import urllib.error nested = event.get("tool_call")
url = BASE_URL.rstrip("/") + "/v1/operations" if not isinstance(nested, dict):
body = json.dumps(eor).encode("utf-8") nested = {{}}
headers = {{"Content-Type": "application/json", "Accept": "application/json"}} tool_name = event.get("tool_name", event.get("toolName", event.get("name", nested.get("name", "unknown"))))
req = urllib.request.Request(url, data=body, headers=headers, method="POST") tool_input = event.get("tool_input", event.get("toolInput", event.get("input", nested.get("input", {{}}))))
resp = urllib.request.urlopen(req, timeout=5) session_id = event.get(
# Server accepted (2xx) — safe to advance local chain state "conversation_id",
event.get("session_id", event.get("sessionId", event.get("session", event.get("taskId", "unknown")))),
)
previous = read_chain_state()
operation_id = generate_uuidv7()
issued_at = int(time.time() * 1000)
payload = event if NATIVE_PAYLOAD else {{
"tool_name": tool_name,
"tool_input": tool_input,
"session_id": session_id,
}}
payload_hash = compute_payload_hash(payload)
chain_hash = compute_chain_hash(previous, payload_hash, operation_id, issued_at)
unsigned = {{
"op_version": "1.0",
"operation_id": operation_id,
"org_id": runtime["org_id"],
"agent_id": runtime["agent_id"],
"issued_at": issued_at,
"ttl_ms": 30000,
"nonce": base64url_encode(os.urandom(16)),
"operation_type": "ai.tool_use",
"subject": {{"session_id": session_id}},
"action": {{"tool": tool_name}},
"payload": payload,
"payload_hash": payload_hash,
"prev_chain_hash": previous,
"agent_pubkey_kid": runtime["kid"],
}}
signature = sign_ed25519(
read_private_key(),
jcs_canonicalize(unsigned).encode("utf-8"),
)
operation = dict(unsigned)
operation.update({{"chain_hash": chain_hash, "signature": signature}})
url = runtime.get("base_url", "https://api.elydora.com").rstrip("/") + "/v1/operations"
headers = {{"Content-Type": "application/json", "Accept": "application/json"}}
if runtime.get("token"):
headers["Authorization"] = "Bearer " + runtime["token"]
request = urllib.request.Request(
url,
data=json.dumps(operation).encode("utf-8"),
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=5):
write_chain_state(chain_hash) write_chain_state(chain_hash)
except urllib.error.HTTPError as http_err: except urllib.error.HTTPError as error:
if http_err.code == 400: body = error.read().decode("utf-8")
# Possible chain hash mismatch — try to resync from server response if error.code == 400 and body:
try: try:
import re failure = json.loads(body)
err_body = json.loads(http_err.read().decode("utf-8")) except (json.JSONDecodeError, ValueError) as parse_error:
if isinstance(err_body, dict) and isinstance(err_body.get("error"), dict): raise RuntimeError("Audit API returned invalid error JSON: " + str(parse_error)) from parse_error
if err_body["error"].get("code") == "PREV_HASH_MISMATCH": details = failure.get("error", {{}}) if isinstance(failure, dict) else {{}}
match = re.search(r'Expected prev_chain_hash "([^"]+)"', err_body["error"].get("message", "")) if details.get("code") == "PREV_HASH_MISMATCH":
if match: match = re.search(
write_chain_state(match.group(1)) r'Expected prev_chain_hash "([A-Za-z0-9_-]{{43}})"',
log_error("Chain hash resynced to server: " + match.group(1)) str(details.get("message", "")),
except Exception: )
pass # ignore parse errors if match:
else: write_chain_state(match.group(1))
log_error("API returned HTTP " + str(http_err.code)) log_error("Chain hash resynced to server: " + match.group(1))
except Exception: raise RuntimeError("Audit API returned HTTP " + str(error.code)) from error
pass # Network error — don't advance chain state, will retry with same prev_chain_hash
except Exception as e:
log_error(e)
if __name__ == "__main__":
main()
if SUCCESS_OUTPUT:
sys.stdout.write(SUCCESS_OUTPUT)
'''
def generate_guard_script(
agent_name: str,
agent_id: str,
success_output: str = "",
) -> str:
"""Return a self-contained Python guard script for PreToolUse freeze enforcement.
The script:
1. Reads agent config from ~/.elydora/{agent_id}/config.json
2. Checks cached status from ~/.elydora/{agent_id}/status-cache.json (60s TTL)
3. If cache is stale, fetches agent status from GET {base_url}/v1/agents/{agent_id}
4. If status is "frozen", writes error to stderr and exits with blocking code 2
5. Fail-open: if API unreachable or config missing, exits 0 (allow)
6. Uses only stdlib — no external deps
"""
shebang = sys.executable
return f'''#!{shebang}
"""Elydora agent guard (PreToolUse) for: {agent_name}
Generated by elydora-sdk — DO NOT EDIT."""
import json
import os
import sys
import time
AGENT_NAME = {agent_name!r}
AGENT_ID = {agent_id!r}
SUCCESS_OUTPUT = {success_output!r}
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
CONFIG_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "config.json")
STATUS_CACHE_PATH = os.path.join(ELYDORA_DIR, AGENT_ID, "status-cache.json")
CACHE_TTL_S = 60 # 60 seconds
# Consume stdin so the parent process doesn't block on a full pipe
try:
sys.stdin.read()
except Exception:
pass
def main(): def main():
# Read agent config
try: try:
with open(CONFIG_PATH, "r") as f: submit_operation(read_event(), read_runtime_config())
config = json.load(f) except Exception as error:
except Exception: log_error(error)
return # Can't read config — fail-open (allow) if FAIL_CLOSED:
raise
# Check cached status
try:
with open(STATUS_CACHE_PATH, "r") as f:
cache = json.load(f)
age = time.time() - cache.get("cached_at", 0)
if age < CACHE_TTL_S:
if cache.get("status") == "frozen":
sys.stderr.write(
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
)
sys.exit(2)
return # Cache is fresh — use cached result
except Exception:
pass # No cache or invalid — need to check API
# Fetch agent status from API
base_url = config.get("base_url", "https://api.elydora.com").rstrip("/")
agent_id = config.get("agent_id", "")
try:
import urllib.request
import urllib.error
url = base_url + "/v1/agents/" + urllib.request.quote(agent_id, safe="")
req = urllib.request.Request(url, method="GET")
req.add_header("Accept", "application/json")
token = config.get("token", "")
if token:
req.add_header("Authorization", "Bearer " + token)
resp = urllib.request.urlopen(req, timeout=3)
data = json.loads(resp.read().decode("utf-8"))
agent_status = "active"
if isinstance(data, dict) and isinstance(data.get("agent"), dict):
agent_status = data["agent"].get("status", "active")
# Update cache
try:
os.makedirs(os.path.dirname(STATUS_CACHE_PATH), exist_ok=True)
with open(STATUS_CACHE_PATH, "w") as f:
json.dump({{"status": agent_status, "cached_at": time.time()}}, f)
except Exception:
pass # Ignore cache write failures
if agent_status == "frozen":
sys.stderr.write(
'Agent "' + AGENT_NAME + '" is frozen by Elydora. Tool execution blocked.\\n'
)
sys.exit(2)
except SystemExit:
raise # Re-raise sys.exit
except Exception:
pass # API unreachable — fail-open (allow)
if __name__ == "__main__": if __name__ == "__main__":
main() try:
main()
except Exception as error:
sys.stderr.write("[Elydora audit] " + repr(error) + "\\n")
raise SystemExit(1)
if SUCCESS_OUTPUT: if SUCCESS_OUTPUT:
sys.stdout.write(SUCCESS_OUTPUT) sys.stdout.write(SUCCESS_OUTPUT)
''' '''
+1 -1
View File
@@ -33,7 +33,7 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
}, },
"cursor": { "cursor": {
"name": "Cursor", "name": "Cursor",
"hook_event": "PreToolUse/PostToolUse", "hook_event": "preToolUse/postToolUse/postToolUseFailure",
"config_path": "~/.cursor/hooks.json", "config_path": "~/.cursor/hooks.json",
}, },
"droid": { "droid": {
@@ -0,0 +1,44 @@
"""Protected runtime-file reader embedded in generated hook scripts."""
PROTECTED_RUNTIME_READER = r'''
MAX_PROTECTED_SECRET_BYTES = 64 * 1024
MAX_PROTECTED_CONFIG_BYTES = 512 * 1024
def read_protected_file(
file_path,
label,
max_bytes=MAX_PROTECTED_SECRET_BYTES,
require_private_permissions=True,
):
before = os.lstat(file_path)
if not stat.S_ISREG(before.st_mode) or stat.S_ISLNK(before.st_mode):
raise ValueError(label + " path is not a physical file: " + file_path)
if before.st_size > max_bytes:
raise ValueError(label + " exceeds the size limit: " + file_path)
if require_private_permissions and os.name != "nt" and before.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
raise PermissionError(label + " permissions are too broad: " + file_path)
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(file_path, flags)
try:
after = os.fstat(descriptor)
if not stat.S_ISREG(after.st_mode):
raise ValueError(label + " path is not a physical file: " + file_path)
if after.st_size > max_bytes:
raise ValueError(label + " exceeds the size limit: " + file_path)
if require_private_permissions and os.name != "nt" and after.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
raise PermissionError(label + " permissions are too broad: " + file_path)
if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino):
raise OSError(label + " changed while opening: " + file_path)
with os.fdopen(descriptor, "rb") as file:
descriptor = -1
value = file.read(max_bytes + 1)
finally:
if descriptor >= 0:
os.close(descriptor)
if len(value) > max_bytes:
raise ValueError(label + " exceeds the size limit: " + file_path)
return value
'''
+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, opencode,
) )
from elydora.plugins.hook_template import generate_hook_script 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() 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) 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 not in script
assert "PRIVATE_KEY_PATH" 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" runtime_root = case_root / ".elydora"
guard_path = runtime_root / "agent-1" / "guard.py" guard_path = runtime_root / "agent-1" / "guard.py"
guard_path.parent.mkdir(parents=True) 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: if module is cursor:
monkeypatch.setenv("HOME", str(case_root)) monkeypatch.setenv("HOME", str(case_root))
monkeypatch.setenv("USERPROFILE", str(case_root)) monkeypatch.setenv("USERPROFILE", str(case_root))
+115 -360
View File
@@ -1,156 +1,45 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass
import json import json
import os import os
from pathlib import Path from pathlib import Path
import subprocess
import sys import sys
import time from typing import Any
from typing import Any, Mapping
import pytest import pytest
from elydora.plugins import cursor from cursor_support import (
from elydora.plugins.cursor import CursorPlugin AGENT_ID,
from elydora.plugins.hook_template import generate_guard_script assert_native_handler,
managed_handler,
prepare_fixture,
write_json,
)
from elydora.plugins.registry import SUPPORTED_AGENTS from elydora.plugins.registry import SUPPORTED_AGENTS
AGENT_ID = "agent-1" EVENTS = (
("preToolUse", "guard.py"),
("postToolUse", "hook.py"),
("postToolUseFailure", "hook.py"),
)
@dataclass def test_cursor_registry_declares_complete_native_contract() -> None:
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:
assert SUPPORTED_AGENTS["cursor"] == { assert SUPPORTED_AGENTS["cursor"] == {
"name": "Cursor", "hook_event": "PreToolUse/PostToolUse", "name": "Cursor",
"hook_event": "preToolUse/postToolUse/postToolUseFailure",
"config_path": "~/.cursor/hooks.json", "config_path": "~/.cursor/hooks.json",
} }
def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent( def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = _prepare_fixture(monkeypatch, tmp_path) fixture = prepare_fixture(monkeypatch, tmp_path)
_write_json( write_json(
fixture.config_path, fixture.config_path,
{ {
"description": "user-owned", "description": "user-owned",
@@ -168,92 +57,29 @@ def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent(
fixture.install() fixture.install()
fixture.install() fixture.install()
settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) settings = fixture.settings()
assert settings["version"] == 1 assert settings["version"] == 1
assert settings["description"] == "user-owned" assert settings["description"] == "user-owned"
assert settings["hooks"]["sessionStart"] == [{"command": "user-session"}] assert settings["hooks"]["sessionStart"] == [{"command": "user-session"}]
assert settings["hooks"]["preToolUse"][0] == {"command": "user-pre"} assert settings["hooks"]["preToolUse"][0] == {"command": "user-pre"}
assert len(settings["hooks"]["preToolUse"]) == 2 assert len(settings["hooks"]["preToolUse"]) == 2
assert len(settings["hooks"]["postToolUse"]) == 1 assert len(settings["hooks"]["postToolUse"]) == 1
_assert_native_handler(_managed_handler(settings, "preToolUse", "guard.py")) assert len(settings["hooks"]["postToolUseFailure"]) == 1
_assert_native_handler(_managed_handler(settings, "postToolUse", "hook.py")) for event, script in EVENTS:
def test_handlers_block_frozen_agent_and_forward_official_input_byte_for_byte( assert_native_handler(managed_handler(settings, event, script))
def test_status_requires_complete_contract_identity_and_physical_runtimes(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = _prepare_fixture(monkeypatch, tmp_path) 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.install() fixture.install()
assert fixture.plugin.status()["installed"] is True assert fixture.plugin.status()["installed"] is True
settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) settings = fixture.settings()
_managed_handler(settings, "preToolUse", "guard.py")["failClosed"] = False settings["hooks"].pop("postToolUseFailure")
_write_json(fixture.config_path, settings) write_json(fixture.config_path, settings)
assert fixture.plugin.status()["installed"] is False assert fixture.plugin.status()["installed"] is False
fixture.install() fixture.install()
@@ -262,21 +88,21 @@ def test_status_requires_exact_pair_identity_and_physical_runtimes(
assert fixture.plugin.status()["installed"] is False assert fixture.plugin.status()["installed"] is False
fixture.hook_path.write_text(hook_source, encoding="utf-8") fixture.hook_path.write_text(hook_source, encoding="utf-8")
_write_json( write_json(
fixture.runtime_config_path, fixture.runtime_config_path,
{"agent_id": "another-agent", "agent_name": "cursor"}, {"agent_id": "another-agent", "agent_name": "cursor"},
) )
assert fixture.plugin.status()["installed"] is False 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"): with pytest.raises(ValueError, match="parse Elydora runtime config"):
fixture.plugin.status() 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, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = _prepare_fixture( fixture = prepare_fixture(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
existing_config={ existing_config={
@@ -285,31 +111,28 @@ def test_uninstall_uses_exact_ownership_and_preserves_user_entries(
}, },
) )
fixture.install() fixture.install()
settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) settings = fixture.settings()
other_guard = deepcopy(_managed_handler(settings, "preToolUse", "guard.py")) for event, script in EVENTS:
other_audit = deepcopy(_managed_handler(settings, "postToolUse", "hook.py")) other = deepcopy(managed_handler(settings, event, script))
other_guard["command"] = other_guard["command"].replace(AGENT_ID, "agent-10") other["command"] = other["command"].replace(AGENT_ID, "agent-10")
other_audit["command"] = other_audit["command"].replace(AGENT_ID, "agent-10") settings["hooks"][event].append(other)
settings["hooks"]["preToolUse"].extend( settings["hooks"]["preToolUse"].append(
[ {
other_guard, "command": "echo elydora agent-1 guard.py",
{ "timeout": 10,
"command": "echo elydora agent-1 guard.py", "failClosed": True,
"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) 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 remaining["hooks"]["sessionStart"] == [{"command": "keep"}]
assert len(remaining["hooks"]["preToolUse"]) == 2 assert len(remaining["hooks"]["preToolUse"]) == 2
assert len(remaining["hooks"]["postToolUse"]) == 1 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( @pytest.mark.parametrize(
@@ -331,170 +154,102 @@ def test_invalid_config_is_preserved_before_runtime_writes(
tmp_path: Path, tmp_path: Path,
existing_config: str, existing_config: str,
) -> None: ) -> None:
fixture = _prepare_fixture( fixture = prepare_fixture(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
existing_config=existing_config, existing_config=existing_config,
) )
original_guard = fixture.guard_path.read_text(encoding="utf-8")
with pytest.raises(ValueError): with pytest.raises(ValueError):
fixture.install() fixture.install()
assert fixture.config_path.read_text(encoding="utf-8") == existing_config assert fixture.config_path.read_text(encoding="utf-8") == existing_config
assert fixture.guard_path.read_text(encoding="utf-8") == original_guard for path in (
assert fixture.hook_path.exists() is False fixture.guard_path,
assert fixture.runtime_config_path.exists() is False fixture.hook_path,
assert fixture.private_key_path.exists() is False 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, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = _prepare_fixture(monkeypatch, tmp_path, create_guard=False) fixture = prepare_fixture(monkeypatch, tmp_path)
fixture.install() 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) other = prepare_fixture(monkeypatch, tmp_path / "unmanaged")
unmanaged = fixture.home_dir / "unmanaged-guard.py" unmanaged = other.home_dir / "unmanaged-guard.py"
unmanaged.write_text("pass\n", encoding="utf-8") 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"): with pytest.raises(ValueError, match="managed agent directory"):
fixture.install() other.install()
assert fixture.config_path.exists() is False assert other.config_path.exists() is False
@pytest.mark.parametrize("runtime_name", ["guard.py", "hook.py"]) def test_uninstall_removes_entirely_managed_config_and_preserves_absence(
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(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = _prepare_fixture(monkeypatch, tmp_path) 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.install() fixture.install()
fixture.plugin.uninstall(AGENT_ID) fixture.plugin.uninstall(AGENT_ID)
assert fixture.config_path.exists() is False assert fixture.config_path.exists() is False
fixture.plugin.uninstall(AGENT_ID) fixture.plugin.uninstall(AGENT_ID)
assert fixture.config_path.exists() is False 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) 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, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
fixture = _prepare_fixture( if os.name == "nt":
monkeypatch, pytest.skip("POSIX mode contract")
tmp_path, fixture = prepare_fixture(monkeypatch, tmp_path)
existing_config={"version": 1, "hooks": {"sessionStart": []}}, fixture.install()
create_guard=False, assert fixture.guard_path.stat().st_mode & 0o777 == 0o700
) assert fixture.hook_path.stat().st_mode & 0o777 == 0o700
original = fixture.config_path.read_text(encoding="utf-8") assert fixture.runtime_config_path.stat().st_mode & 0o777 == 0o600
real_replace = os.replace assert fixture.private_key_path.stat().st_mode & 0o777 == 0o600
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"))
+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 subprocess
import sys import sys
from threading import Thread from threading import Thread
import time
from elydora.plugins.hook_template import generate_guard_script 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") script_path, server = create_guard(tmp_path, "active")
cache_path = tmp_path / ".elydora" / AGENT_ID / "status-cache.json" cache_path = tmp_path / ".elydora" / AGENT_ID / "status-cache.json"
cache_path.write_text( 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", encoding="utf-8",
) )
try: try: