From 3dce6c0b8de720d1dbcfff962a9d20797982295b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 22:46:25 -0400 Subject: [PATCH] fix: harden GitHub Copilot hooks --- README.md | 2 +- elydora/plugins/copilot.py | 327 ++++++++++----------- elydora/plugins/copilot_contract.py | 242 +++++++++++++++ elydora/plugins/copilot_io.py | 96 ++++++ elydora/plugins/registry.py | 6 +- tests/test_copilot_plugin.py | 439 ++++++++++++++++++++++++++++ 6 files changed, 939 insertions(+), 173 deletions(-) create mode 100644 elydora/plugins/copilot_contract.py create mode 100644 elydora/plugins/copilot_io.py create mode 100644 tests/test_copilot_plugin.py diff --git a/README.md b/README.md index 281a8c2..0be78ad 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera | Cline | `cline` | | Kimi Code | `kimi` | | Grok Build | `grok` | -| Copilot CLI | `copilot` | +| GitHub Copilot CLI | `copilot` | | Cursor | `cursor` | | Factory Droid | `droid` | | Gemini CLI | `gemini` | diff --git a/elydora/plugins/copilot.py b/elydora/plugins/copilot.py index c95163e..32da3ac 100644 --- a/elydora/plugins/copilot.py +++ b/elydora/plugins/copilot.py @@ -1,199 +1,188 @@ -"""Copilot CLI plugin — merges preToolUse/postToolUse hooks into .github/hooks/hooks.json (project-relative).""" +"""GitHub Copilot CLI user-hook integration.""" from __future__ import annotations import json import os -import sys +from typing import List, Optional -from ._file_io import write_json_atomic, write_text_atomic +from ._transaction import ( + FileChange, + file_change, + require_runtime, + write_changes, +) from .base import AgentPlugin, InstallConfig, PluginStatus +from .copilot_contract import ( + AGENT_KEY, + AUDIT_SCRIPT, + CopilotHooks, + CopilotSources, + RuntimeContract, + build_handler, + remove_managed_hooks, + render_document, + runtime_contracts, +) +from .copilot_io import read_sources, rendered_change, runtime_files_exist from .hook_template import generate_hook_script ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora") -def _settings_path() -> str: - return os.path.join(os.getcwd(), ".github", "hooks", "hooks.json") +def _home_dir() -> str: + return os.path.expanduser("~") + + +def _json_source(value: dict) -> str: + return json.dumps(value, indent=2) + "\n" + + +def _runtime_config(config: InstallConfig, agent_id: str) -> dict: + value = { + "org_id": config.get("org_id", ""), + "agent_id": agent_id, + "kid": config.get("kid", ""), + "base_url": config.get("base_url", "https://api.elydora.com"), + "agent_name": AGENT_KEY, + } + token = config.get("token", "") + if token: + value["token"] = token + return value + + +def _rendered_changes( + sources: CopilotSources, + user_hooks: CopilotHooks, + agent_id: str = "", +) -> List[FileChange]: + rendered = [render_document(sources.user, user_hooks)] + if sources.legacy is not None: + rendered.append(render_document( + sources.legacy, + remove_managed_hooks( + sources.legacy.hooks, + ELYDORA_DIR, + agent_id, + ), + )) + return [ + change + for item in rendered + for change in [rendered_change(item)] + if change is not None + ] + + +def _contracts(sources: CopilotSources) -> List[RuntimeContract]: + contracts = runtime_contracts(sources.user.hooks, ELYDORA_DIR) + if sources.legacy is not None: + contracts.extend(runtime_contracts(sources.legacy.hooks, ELYDORA_DIR)) + unique = {} + for contract in contracts: + unique[os.path.normcase(contract.agent_id)] = contract + return list(unique.values()) + + +def _configured_path( + sources: CopilotSources, + contracts: List[RuntimeContract], +) -> str: + user_ids = { + os.path.normcase(contract.agent_id) + for contract in runtime_contracts(sources.user.hooks, ELYDORA_DIR) + } + if any(os.path.normcase(contract.agent_id) in user_ids for contract in contracts): + return sources.user.file_path + if sources.legacy is not None: + return sources.legacy.file_path + return sources.user.file_path class CopilotPlugin(AgentPlugin): - """Install/uninstall Elydora audit hook for Copilot CLI.""" - - @staticmethod - def _hook_path_for(agent_id: str) -> str: - return os.path.join(ELYDORA_DIR, agent_id, "hook.py") + """Install Elydora into GitHub Copilot CLI's global user hooks.""" def install(self, config: InstallConfig) -> None: agent_id = config.get("agent_id", "") - agent_name = config.get("agent_name", "") + if not agent_id: + raise ValueError("agent_id is required") + sources = read_sources(_home_dir()) + guard_path = config.get("guard_script_path", "") + require_runtime(guard_path, "Elydora guard runtime") - # Create per-agent directory - agent_dir = os.path.join(ELYDORA_DIR, agent_id) - os.makedirs(agent_dir, exist_ok=True) + agent_directory = os.path.join(ELYDORA_DIR, agent_id) + audit_path = os.path.join(agent_directory, AUDIT_SCRIPT) + user_hooks = remove_managed_hooks(sources.user.hooks, ELYDORA_DIR) + user_hooks["preToolUse"] = [ + *user_hooks.get("preToolUse", []), + build_handler(guard_path), + ] + user_hooks["postToolUse"] = [ + *user_hooks.get("postToolUse", []), + build_handler(audit_path), + ] - # Write config.json - config_data = { - "org_id": config.get("org_id", ""), - "agent_id": agent_id, - "kid": config.get("kid", ""), - "base_url": config.get("base_url", "https://api.elydora.com"), - "token": config.get("token", ""), - "agent_name": agent_name, - } - config_path = os.path.join(agent_dir, "config.json") - write_json_atomic( - config_path, - config_data, - 0o600, - "Elydora runtime config", - ) - - # Write private key - private_key_path = os.path.join(agent_dir, "private.key") - write_text_atomic( - private_key_path, - config.get("private_key", ""), - 0o600, - "Elydora private key", - ) - - script = generate_hook_script( + audit_script = generate_hook_script( org_id=config.get("org_id", ""), agent_id=agent_id, kid=config.get("kid", ""), base_url=config.get("base_url", "https://api.elydora.com"), ) - hook_path = self._hook_path_for(agent_id) - write_text_atomic( - hook_path, - script, - 0o700, - "Elydora audit runtime", - ) - - guard_script_path = config.get("guard_script_path", "") - python_exe = sys.executable - - settings_path = _settings_path() - settings = _load_json(settings_path) - settings["version"] = 1 - hooks = settings.setdefault("hooks", {}) - - # --- preToolUse (guard — freeze enforcement, camelCase) --- - pre_tool_use = hooks.setdefault("preToolUse", []) - pre_tool_use[:] = [h for h in pre_tool_use if not _is_elydora_hook(h)] - if guard_script_path: - pre_tool_use.append({ - "type": "command", - "bash": f'"{python_exe}" {guard_script_path}', - "powershell": f'"{python_exe}" {guard_script_path}', - "timeoutSec": 5, - }) - - # --- postToolUse (audit logging, camelCase) --- - post_tool_use = hooks.setdefault("postToolUse", []) - - post_tool_use[:] = [h for h in post_tool_use if not _is_elydora_hook(h)] - - post_tool_use.append({ - "type": "command", - "bash": hook_path, - "powershell": hook_path, - "timeoutSec": 5, - }) - - _save_json(settings_path, settings) - print("Elydora hook installed for Copilot CLI.") - print(f" Hook script: {hook_path}") - print(f" Settings: {settings_path}") + candidates: List[Optional[FileChange]] = [ + file_change( + os.path.join(agent_directory, "config.json"), + "Elydora runtime config", + _json_source(_runtime_config(config, agent_id)), + 0o600, + ), + file_change( + os.path.join(agent_directory, "private.key"), + "Elydora private key", + config.get("private_key", ""), + 0o600, + ), + file_change( + audit_path, + "Elydora audit runtime", + audit_script, + 0o700, + ), + ] + changes = [change for change in candidates if change is not None] + changes.extend(_rendered_changes(sources, user_hooks)) + write_changes(changes, "Install GitHub Copilot hooks") + print(f"GitHub Copilot CLI hooks: {sources.user.file_path}") def uninstall(self, agent_id: str = "") -> None: - settings_path = _settings_path() - if os.path.exists(settings_path): - settings = _load_json(settings_path) - hooks = settings.get("hooks", {}) - changed = False - - # Remove preToolUse entries - pre_tool_use = hooks.get("preToolUse", []) - pre_filtered = [h for h in pre_tool_use if not _is_elydora_hook(h, agent_id)] - if len(pre_filtered) != len(pre_tool_use): - hooks["preToolUse"] = pre_filtered - if not pre_filtered: - del hooks["preToolUse"] - changed = True - - # Remove postToolUse entries - post_tool_use = hooks.get("postToolUse", []) - post_filtered = [h for h in post_tool_use if not _is_elydora_hook(h, agent_id)] - if len(post_filtered) != len(post_tool_use): - hooks["postToolUse"] = post_filtered - if not post_filtered: - del hooks["postToolUse"] - changed = True - - if changed: - if not hooks: - del settings["hooks"] - _save_json(settings_path, settings) - - # Hook script removal is handled by cli.py cmd_uninstall (rmtree of agent dir) - print("Elydora hook uninstalled from Copilot CLI.") + sources = read_sources(_home_dir()) + user_hooks = remove_managed_hooks( + sources.user.hooks, + ELYDORA_DIR, + agent_id, + ) + changes = _rendered_changes(sources, user_hooks, agent_id) + write_changes(changes, "Uninstall GitHub Copilot hooks") def status(self) -> PluginStatus: - # Scan ~/.elydora/*/hook.py for any installed hook - import glob as _glob - hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py") - hook_files = _glob.glob(hook_pattern) - hook_exists = len(hook_files) > 0 - - settings_configured = False - settings_path = _settings_path() - if os.path.exists(settings_path): - settings = _load_json(settings_path) - hooks = settings.get("hooks", {}) - pre_tool_use = hooks.get("preToolUse", []) - post_tool_use = hooks.get("postToolUse", []) - pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use) - post_configured = any(_is_elydora_hook(h) for h in post_tool_use) - settings_configured = pre_configured and post_configured - - installed = hook_exists and settings_configured - if installed: - details = f"Found {len(hook_files)} agent(s): {', '.join(hook_files)}" - elif hook_exists: - details = "Hook script exists but not configured in hooks.json" - elif settings_configured: - details = "Configured in hooks.json but hook script missing" - else: - details = "Not installed" - - return PluginStatus(installed=installed, agent="copilot", details=details) - - -def _is_elydora_hook(hook: dict, agent_id: str = "") -> bool: - # Check both bash and powershell fields for Copilot's hook format - for field in ("bash", "powershell", "command"): - cmd = hook.get(field, "") - if not isinstance(cmd, str): - continue - if "elydora" not in cmd.lower(): - continue - if agent_id and agent_id in cmd: - return True - if not agent_id: - return True - return False - - -def _load_json(path: str) -> dict: - if not os.path.exists(path): - return {} - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def _save_json(path: str, data: dict) -> None: - write_json_atomic(path, data, 0o600, "GitHub Copilot hooks config") + sources = read_sources(_home_dir()) + contracts = _contracts(sources) + if not contracts: + return PluginStatus( + installed=False, + agent=AGENT_KEY, + details="Not installed", + ) + installed = runtime_files_exist(contracts) + config_path = _configured_path(sources, contracts) + details = ( + f"Config: {config_path}" + if installed + else f"Configured at {config_path}; runtime scripts missing" + ) + return PluginStatus( + installed=installed, + agent=AGENT_KEY, + details=details, + ) diff --git a/elydora/plugins/copilot_contract.py b/elydora/plugins/copilot_contract.py new file mode 100644 index 0000000..c9a5663 --- /dev/null +++ b/elydora/plugins/copilot_contract.py @@ -0,0 +1,242 @@ +"""GitHub Copilot hook contract, ownership, and rendering.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +import re +import shlex +import sys +from typing import Any, Dict, List, Optional, Set + + +AGENT_KEY = "copilot" +GUARD_SCRIPT = "guard.py" +AUDIT_SCRIPT = "hook.py" +CONFIG_FILE = "elydora-audit.json" +HOOK_TIMEOUT_SECONDS = 10 + +JsonObject = Dict[str, Any] +CopilotHooks = Dict[str, List[JsonObject]] + + +@dataclass(frozen=True) +class CopilotDocument: + exists: bool + file_path: str + root: JsonObject + hooks: CopilotHooks + raw: Optional[str] = None + + +@dataclass(frozen=True) +class CopilotSources: + user: CopilotDocument + legacy: Optional[CopilotDocument] + + +@dataclass(frozen=True) +class RenderedDocument: + document: CopilotDocument + changed: bool + next_source: Optional[str] = None + + +@dataclass(frozen=True) +class RuntimeContract: + agent_id: str + guard_path: str + audit_path: str + + +def _same_path(left: str, right: str) -> bool: + return os.path.normcase(os.path.abspath(left)) == os.path.normcase( + os.path.abspath(right) + ) + + +def _same_agent_id(left: str, right: str) -> bool: + return os.path.normcase(left) == os.path.normcase(right) + + +def _quote_powershell(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def build_handler(script_path: str) -> JsonObject: + return { + "type": "command", + "bash": shlex.join([sys.executable, script_path]), + "powershell": ( + f"& {_quote_powershell(sys.executable)} " + f"{_quote_powershell(script_path)}; exit $LASTEXITCODE" + ), + "timeoutSec": HOOK_TIMEOUT_SECONDS, + } + + +def _generated_script_path(handler: JsonObject) -> Optional[str]: + if set(handler) != {"type", "bash", "powershell", "timeoutSec"}: + return None + if handler.get("type") != "command": + return None + if handler.get("timeoutSec") != HOOK_TIMEOUT_SECONDS: + return None + bash = handler.get("bash") + if not isinstance(bash, str): + return None + try: + arguments = shlex.split(bash) + except ValueError: + return None + if len(arguments) != 2 or not _same_path(arguments[0], sys.executable): + return None + script_path = arguments[1] + expected_powershell = ( + f"& {_quote_powershell(sys.executable)} " + f"{_quote_powershell(script_path)}; exit $LASTEXITCODE" + ) + return script_path if handler.get("powershell") == expected_powershell else None + + +def _legacy_script_path(handler: JsonObject) -> Optional[str]: + if set(handler) != {"type", "bash", "powershell", "timeoutSec"}: + return None + if handler.get("type") != "command" or handler.get("timeoutSec") != 5: + return None + bash = handler.get("bash") + powershell = handler.get("powershell") + if not isinstance(bash, str) or bash != powershell: + return None + command = re.fullmatch(r'"[^"]+"\s+(.+)', bash) + return command.group(1) if command else bash + + +def _managed_agent_id( + handler: JsonObject, + script_name: str, + runtime_root: str, +) -> Optional[str]: + script_path = _generated_script_path(handler) or _legacy_script_path(handler) + if not script_path or os.path.basename(script_path) != script_name: + return None + agent_directory = os.path.dirname(script_path) + if not _same_path(os.path.dirname(agent_directory), runtime_root): + return None + agent_id = os.path.basename(agent_directory) + return agent_id if agent_id not in {"", ".", ".."} else None + + +def _validate_hooks(value: Any, label: str) -> CopilotHooks: + if value is None: + raise ValueError(f'{label} field "hooks" must be an object') + if not isinstance(value, dict): + raise ValueError(f'{label} field "hooks" must be an object') + hooks: CopilotHooks = {} + for event, handlers in value.items(): + if not isinstance(event, str): + raise ValueError(f"{label} hook event names must be strings") + if not isinstance(handlers, list): + raise ValueError(f'{label} field "hooks.{event}" must be an array') + if not all(isinstance(handler, dict) for handler in handlers): + raise ValueError( + f'{label} field "hooks.{event}" must contain objects' + ) + hooks[event] = list(handlers) + return hooks + + +def parse_document(file_path: str, raw: str, label: str) -> CopilotDocument: + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise ValueError(f"Failed to parse {label} at {file_path}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"{label} at {file_path} must contain a JSON object") + if value.get("version") != 1: + raise ValueError(f"{label} at {file_path} must declare version 1") + hooks = _validate_hooks(value.get("hooks", {}), label) + return CopilotDocument(True, file_path, value, hooks, raw) + + +def create_document(file_path: str) -> CopilotDocument: + return CopilotDocument(False, file_path, {}, {}) + + +def remove_managed_hooks( + hooks: CopilotHooks, + runtime_root: str, + agent_id: str = "", +) -> CopilotHooks: + result = dict(hooks) + for event, script_name in ( + ("preToolUse", GUARD_SCRIPT), + ("postToolUse", AUDIT_SCRIPT), + ): + handlers = [] + for handler in result.get(event, []): + managed_id = _managed_agent_id(handler, script_name, runtime_root) + remove = managed_id is not None and ( + not agent_id or _same_agent_id(managed_id, agent_id) + ) + if not remove: + handlers.append(handler) + if handlers: + result[event] = handlers + else: + result.pop(event, None) + return result + + +def _empty_owned_document(root: JsonObject, hooks: CopilotHooks) -> bool: + return not hooks and all(key in {"version", "hooks"} for key in root) + + +def render_document( + document: CopilotDocument, + hooks: CopilotHooks, +) -> RenderedDocument: + if not document.exists and not hooks: + return RenderedDocument(document, False) + if document.exists and _empty_owned_document(document.root, hooks): + return RenderedDocument(document, True) + root = {**document.root, "version": 1} + if hooks: + root["hooks"] = hooks + else: + root.pop("hooks", None) + next_source = json.dumps(root, indent=2) + "\n" + return RenderedDocument(document, next_source != document.raw, next_source) + + +def _managed_ids( + handlers: List[JsonObject], + script_name: str, + runtime_root: str, +) -> Set[str]: + result: Set[str] = set() + for handler in handlers: + agent_id = _managed_agent_id(handler, script_name, runtime_root) + if agent_id: + result.add(agent_id) + return result + + +def runtime_contracts( + hooks: CopilotHooks, + runtime_root: str, +) -> List[RuntimeContract]: + guards = _managed_ids(hooks.get("preToolUse", []), GUARD_SCRIPT, runtime_root) + audits = _managed_ids(hooks.get("postToolUse", []), AUDIT_SCRIPT, runtime_root) + contracts: List[RuntimeContract] = [] + for agent_id in sorted(guards): + if not any(_same_agent_id(agent_id, audit_id) for audit_id in audits): + continue + agent_directory = os.path.join(runtime_root, agent_id) + contracts.append(RuntimeContract( + agent_id, + os.path.join(agent_directory, GUARD_SCRIPT), + os.path.join(agent_directory, AUDIT_SCRIPT), + )) + return contracts diff --git a/elydora/plugins/copilot_io.py b/elydora/plugins/copilot_io.py new file mode 100644 index 0000000..2781b8b --- /dev/null +++ b/elydora/plugins/copilot_io.py @@ -0,0 +1,96 @@ +"""GitHub Copilot hook source and runtime I/O.""" + +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional + +from ._transaction import ( + FileChange, + read_optional, + regular_file_exists, + source_change, +) +from .copilot_contract import ( + AGENT_KEY, + CONFIG_FILE, + CopilotDocument, + CopilotSources, + RenderedDocument, + RuntimeContract, + create_document, + parse_document, +) + + +JsonObject = Dict[str, Any] + + +def config_paths(home_dir: str) -> tuple[str, str]: + override = os.environ.get("COPILOT_HOME", "").strip() + copilot_home = override or os.path.join(home_dir, ".copilot") + user_path = os.path.join(copilot_home, "hooks", CONFIG_FILE) + legacy_path = os.path.join(os.getcwd(), ".github", "hooks", "hooks.json") + return user_path, legacy_path + + +def _read_document(file_path: str, label: str) -> Optional[CopilotDocument]: + raw = read_optional(file_path, label) + return None if raw is None else parse_document(file_path, raw, label) + + +def read_sources(home_dir: str) -> CopilotSources: + user_path, legacy_path = config_paths(home_dir) + user = _read_document(user_path, "GitHub Copilot user hooks") + legacy = _read_document(legacy_path, "GitHub Copilot legacy project hooks") + return CopilotSources(user or create_document(user_path), legacy) + + +def rendered_change(rendered: RenderedDocument) -> Optional[FileChange]: + if not rendered.changed: + return None + return source_change( + rendered.document.file_path, + "GitHub Copilot hook source", + rendered.document.raw, + rendered.next_source, + 0o600, + ) + + +def _read_runtime_config(file_path: str) -> Optional[JsonObject]: + raw = read_optional(file_path, "Elydora runtime config") + if raw is None: + return None + try: + value = json.loads(raw) + except json.JSONDecodeError as error: + raise ValueError( + f"Failed to parse Elydora runtime config at {file_path}: {error}" + ) from error + if not isinstance(value, dict): + raise ValueError( + f"Elydora runtime config at {file_path} must contain a JSON object" + ) + return value + + +def runtime_files_exist(contracts: List[RuntimeContract]) -> bool: + for contract in contracts: + agent_directory = os.path.dirname(contract.guard_path) + config_path = os.path.join(agent_directory, "config.json") + config = _read_runtime_config(config_path) + if ( + config is None + or config.get("agent_name") != AGENT_KEY + or config.get("agent_id") != contract.agent_id + ): + continue + if regular_file_exists( + contract.guard_path, "Elydora guard runtime" + ) and regular_file_exists( + contract.audit_path, "Elydora audit runtime" + ): + return True + return False diff --git a/elydora/plugins/registry.py b/elydora/plugins/registry.py index 45aa2bb..9537030 100644 --- a/elydora/plugins/registry.py +++ b/elydora/plugins/registry.py @@ -27,9 +27,9 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = { "config_path": "~/.cline/hooks/PreToolUse.mjs", }, "copilot": { - "name": "Copilot CLI", - "hook_event": "PostToolUse", - "config_path": ".github/hooks/hooks.json", + "name": "GitHub Copilot CLI", + "hook_event": "preToolUse/postToolUse", + "config_path": "~/.copilot/hooks/elydora-audit.json", }, "cursor": { "name": "Cursor", diff --git a/tests/test_copilot_plugin.py b/tests/test_copilot_plugin.py new file mode 100644 index 0000000..c36f968 --- /dev/null +++ b/tests/test_copilot_plugin.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any + +import pytest + +from elydora import cli +from elydora.plugins import _transaction, copilot +from elydora.plugins.base import InstallConfig +from elydora.plugins.registry import SUPPORTED_AGENTS + + +AGENT_ID = "agent-1" +MISSING = object() + + +@dataclass(frozen=True) +class CopilotFixture: + plugin: copilot.CopilotPlugin + config: InstallConfig + home_dir: Path + copilot_home: Path + project_dir: Path + agent_dir: Path + config_path: Path + legacy_path: Path + guard_path: Path + hook_path: Path + runtime_config_path: Path + private_key_path: Path + + +def write_json_or_text(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + source = value if isinstance(value, str) else json.dumps(value, indent=2) + path.write_text(source, encoding="utf-8") + + +def prepare_fixture( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + *, + user_config: object = MISSING, + legacy_config: object = MISSING, + create_guard: bool = True, +) -> CopilotFixture: + home_dir = tmp_path / "home with spaces" + project_dir = tmp_path / "project with spaces" + copilot_home = home_dir / "custom copilot" + agent_dir = home_dir / ".elydora" / AGENT_ID + config_path = copilot_home / "hooks" / "elydora-audit.json" + legacy_path = project_dir / ".github" / "hooks" / "hooks.json" + guard_path = agent_dir / "guard.py" + hook_path = agent_dir / "hook.py" + agent_dir.mkdir(parents=True) + project_dir.mkdir(parents=True) + if create_guard: + guard_path.write_text( + "import sys\nsys.stdin.read()\n" + "sys.stderr.write('Agent is frozen by Elydora.')\n" + "raise SystemExit(2)\n", + encoding="utf-8", + ) + if user_config is not MISSING: + write_json_or_text(config_path, user_config) + if legacy_config is not MISSING: + write_json_or_text(legacy_path, legacy_config) + + monkeypatch.chdir(project_dir) + monkeypatch.setattr(copilot, "ELYDORA_DIR", str(agent_dir.parent)) + monkeypatch.setattr(copilot, "_home_dir", lambda: str(home_dir), raising=False) + monkeypatch.setenv("COPILOT_HOME", str(copilot_home)) + config: InstallConfig = { + "agent_id": AGENT_ID, + "agent_name": "copilot", + "org_id": "org-1", + "private_key": "test-key", + "kid": "kid-1", + "base_url": "https://api.elydora.test", + "guard_script_path": str(guard_path), + } + return CopilotFixture( + plugin=copilot.CopilotPlugin(), + config=config, + home_dir=home_dir, + copilot_home=copilot_home, + project_dir=project_dir, + agent_dir=agent_dir, + config_path=config_path, + legacy_path=legacy_path, + guard_path=guard_path, + hook_path=hook_path, + runtime_config_path=agent_dir / "config.json", + private_key_path=agent_dir / "private.key", + ) + + +def managed_handler( + settings: dict[str, Any], event: str, script_name: str +) -> dict[str, Any]: + for handler in settings.get("hooks", {}).get(event, []): + if script_name in str(handler.get("bash")): + return handler + raise AssertionError(f"managed {event} handler not found") + + +def assert_native_handler(handler: dict[str, Any]) -> None: + assert set(handler) == {"type", "bash", "powershell", "timeoutSec"} + assert handler["type"] == "command" + assert handler["timeoutSec"] == 10 + assert Path(sys.executable).name in handler["bash"] + assert handler["powershell"].startswith("& ") + assert handler["powershell"].endswith("; exit $LASTEXITCODE") + + +def legacy_managed_config( + fixture: CopilotFixture, + extra_hooks: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "version": 1, + "hooks": { + "preToolUse": [{ + "type": "command", + "bash": f'"{sys.executable}" {fixture.guard_path}', + "powershell": f'"{sys.executable}" {fixture.guard_path}', + "timeoutSec": 5, + }], + "postToolUse": [{ + "type": "command", + "bash": str(fixture.hook_path), + "powershell": str(fixture.hook_path), + "timeoutSec": 5, + }], + **(extra_hooks or {}), + }, + } + + +def run_hook( + handler: dict[str, Any], + home_dir: Path, + payload: str, +) -> subprocess.CompletedProcess[str]: + if os.name == "nt": + command = [ + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-Command", + handler["powershell"], + ] + else: + command = ["/bin/sh", "-c", handler["bash"]] + return subprocess.run( + command, + capture_output=True, + check=False, + env={**os.environ, "HOME": str(home_dir), "USERPROFILE": str(home_dir)}, + input=payload, + text=True, + ) + + +def test_copilot_is_registered_with_native_user_hooks() -> None: + assert SUPPORTED_AGENTS["copilot"] == { + "name": "GitHub Copilot CLI", + "hook_event": "preToolUse/postToolUse", + "config_path": "~/.copilot/hooks/elydora-audit.json", + } + assert cli.PLUGIN_MAP["copilot"] is copilot.CopilotPlugin + + +def test_install_preserves_user_hooks_migrates_legacy_and_is_idempotent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture( + monkeypatch, + tmp_path, + user_config={ + "version": 1, + "disableAllHooks": False, + "hooks": { + "sessionStart": [{"type": "command", "command": "user-session"}], + "preToolUse": [{"type": "command", "command": "user-pre"}], + }, + }, + ) + write_json_or_text( + fixture.legacy_path, + legacy_managed_config(fixture, { + "notification": [{"type": "command", "command": "user-notification"}], + }), + ) + + fixture.plugin.install(fixture.config) + fixture.plugin.install(fixture.config) + + settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) + assert settings["disableAllHooks"] is False + assert settings["hooks"]["sessionStart"] == [ + {"type": "command", "command": "user-session"} + ] + assert settings["hooks"]["preToolUse"][0] == { + "type": "command", + "command": "user-pre", + } + assert len(settings["hooks"]["preToolUse"]) == 2 + assert len(settings["hooks"]["postToolUse"]) == 1 + assert_native_handler(managed_handler(settings, "preToolUse", "guard.py")) + assert_native_handler(managed_handler(settings, "postToolUse", "hook.py")) + legacy = json.loads(fixture.legacy_path.read_text(encoding="utf-8")) + assert legacy == { + "version": 1, + "hooks": { + "notification": [{"type": "command", "command": "user-notification"}], + }, + } + assert fixture.runtime_config_path.is_file() + assert fixture.private_key_path.read_text(encoding="utf-8") == "test-key" + assert fixture.hook_path.is_file() + + +def test_migration_removes_legacy_file_owned_entirely_by_elydora( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path) + write_json_or_text(fixture.legacy_path, legacy_managed_config(fixture)) + + fixture.plugin.install(fixture.config) + + assert fixture.legacy_path.exists() is False + settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) + assert_native_handler(managed_handler(settings, "preToolUse", "guard.py")) + + +def test_commands_block_and_forward_native_payload_byte_for_byte( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + capture_path = tmp_path / "captured-event.json" + fixture.hook_path.write_text( + "from pathlib import Path\nimport sys\n" + f"Path({str(capture_path)!r}).write_text(sys.stdin.read(), encoding='utf-8')\n", + encoding="utf-8", + ) + settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) + pre_payload = json.dumps({ + "sessionId": "session-1", + "timestamp": 1, + "cwd": str(fixture.project_dir), + "toolName": "powershell", + "toolArgs": {"command": "Get-ChildItem"}, + }, separators=(",", ":")) + guard = managed_handler(settings, "preToolUse", "guard.py") + guard_result = run_hook(guard, fixture.home_dir, pre_payload) + assert guard_result.returncode == 2, guard_result.stderr + assert "Agent is frozen by Elydora" in guard_result.stderr + + post_payload = json.dumps({ + "sessionId": "session-1", + "timestamp": 2, + "cwd": str(fixture.project_dir), + "toolName": "powershell", + "toolArgs": {"command": "Get-ChildItem"}, + "toolResult": {"output": "ok"}, + }, separators=(",", ":")) + audit = managed_handler(settings, "postToolUse", "hook.py") + audit_result = run_hook(audit, fixture.home_dir, post_payload) + assert audit_result.returncode == 0, audit_result.stderr + assert capture_path.read_text(encoding="utf-8") == post_payload + + +def test_empty_home_override_uses_official_default( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path) + monkeypatch.setenv("COPILOT_HOME", "") + fixture.plugin.install(fixture.config) + default_path = fixture.home_dir / ".copilot" / "hooks" / "elydora-audit.json" + settings = json.loads(default_path.read_text(encoding="utf-8")) + assert_native_handler(managed_handler(settings, "preToolUse", "guard.py")) + assert str(default_path) in fixture.plugin.status()["details"] + + +def test_status_requires_complete_pair_and_valid_runtime_identity( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + assert fixture.plugin.status()["installed"] is True + settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) + del settings["hooks"]["postToolUse"] + write_json_or_text(fixture.config_path, settings) + assert fixture.plugin.status()["installed"] is False + + fixture.plugin.install(fixture.config) + fixture.runtime_config_path.write_text("{ malformed", encoding="utf-8") + with pytest.raises(ValueError, match="parse Elydora runtime config"): + fixture.plugin.status() + + +def test_uninstall_removes_exact_ownership_and_preserves_user_entries( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture( + monkeypatch, + tmp_path, + user_config={ + "version": 1, + "hooks": {"notification": [{"type": "command", "command": "keep"}]}, + }, + ) + fixture.plugin.install(fixture.config) + settings = json.loads(fixture.config_path.read_text(encoding="utf-8")) + settings["hooks"]["preToolUse"].append({ + "type": "command", + "bash": f"{sys.executable} {fixture.agent_dir.parent / 'agent-10' / 'guard.py'}", + "powershell": "user-decoy", + "timeoutSec": 10, + }) + write_json_or_text(fixture.config_path, settings) + + fixture.plugin.uninstall(AGENT_ID.upper() if os.name == "nt" else AGENT_ID) + + remaining = json.loads(fixture.config_path.read_text(encoding="utf-8")) + assert remaining["hooks"]["notification"] == [ + {"type": "command", "command": "keep"} + ] + assert "postToolUse" not in remaining["hooks"] + assert len(remaining["hooks"]["preToolUse"]) == 1 + assert "agent-10" in remaining["hooks"]["preToolUse"][0]["bash"] + + +def test_uninstall_leaves_absent_sources_absent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.uninstall(AGENT_ID) + assert fixture.config_path.exists() is False + assert fixture.legacy_path.exists() is False + + +@pytest.mark.parametrize( + "existing", + [ + "{ malformed", + {"hooks": {}}, + {"version": 2, "hooks": {}}, + {"version": 1, "hooks": None}, + {"version": 1, "hooks": {"preToolUse": None}}, + {"version": 1, "hooks": {"preToolUse": [None]}}, + ], +) +def test_invalid_config_is_preserved_before_runtime_writes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + existing: object, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path, user_config=existing) + original = fixture.config_path.read_text(encoding="utf-8") + with pytest.raises((ValueError, json.JSONDecodeError)): + fixture.plugin.install(fixture.config) + assert fixture.config_path.read_text(encoding="utf-8") == original + assert fixture.runtime_config_path.exists() is False + assert fixture.private_key_path.exists() is False + assert fixture.hook_path.exists() is False + + +def test_missing_guard_is_rejected_before_creating_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path, create_guard=False) + with pytest.raises(FileNotFoundError, match="guard runtime is missing"): + fixture.plugin.install(fixture.config) + assert fixture.config_path.exists() is False + assert fixture.runtime_config_path.exists() is False + assert fixture.private_key_path.exists() is False + assert fixture.hook_path.exists() is False + + +def test_transaction_rolls_back_every_file_after_commit_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture( + monkeypatch, + tmp_path, + user_config={"version": 1, "hooks": {"notification": []}}, + legacy_config={"version": 1, "hooks": {"sessionStart": []}}, + ) + user_before = fixture.config_path.read_text(encoding="utf-8") + legacy_before = fixture.legacy_path.read_text(encoding="utf-8") + original_commit = _transaction._commit + commits = 0 + + def fail_after_first_commit(staged: Any) -> None: + nonlocal commits + original_commit(staged) + commits += 1 + if commits == 1: + raise OSError("injected commit failure") + + monkeypatch.setattr(_transaction, "_commit", fail_after_first_commit) + with pytest.raises(OSError, match="injected commit failure"): + fixture.plugin.install(fixture.config) + assert fixture.config_path.read_text(encoding="utf-8") == user_before + assert fixture.legacy_path.read_text(encoding="utf-8") == legacy_before + assert fixture.runtime_config_path.exists() is False + assert fixture.private_key_path.exists() is False + assert fixture.hook_path.exists() is False + + +def test_atomic_writes_leave_no_transaction_files( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + fixture = prepare_fixture(monkeypatch, tmp_path) + fixture.plugin.install(fixture.config) + for directory in (fixture.agent_dir, fixture.config_path.parent): + assert all( + path.suffix not in {".tmp", ".rollback"} + for path in directory.iterdir() + )