fix(python): sync hardened Copilot hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 01:22:53 -04:00
1 parent 4d97660cec
commit 91353d9e5e
6 files changed
+935 -169

No files matched your search

+1 -1
View File
@@ -113,7 +113,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera
| Cline | `cline` | | Cline | `cline` |
| Kimi Code | `kimi` | | Kimi Code | `kimi` |
| Grok Build | `grok` | | Grok Build | `grok` |
| Copilot CLI | `copilot` | | GitHub Copilot CLI | `copilot` |
| Cursor | `cursor` | | Cursor | `cursor` |
| Factory Droid | `droid` | | Factory Droid | `droid` |
| Gemini CLI | `gemini` | | Gemini CLI | `gemini` |
+154 -165
View File
@@ -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 from __future__ import annotations
import json import json
import os 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 .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 from .hook_template import generate_hook_script
ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora") ELYDORA_DIR = os.path.join(os.path.expanduser("~"), ".elydora")
def _settings_path() -> str: def _home_dir() -> str:
return os.path.join(os.getcwd(), ".github", "hooks", "hooks.json") return os.path.expanduser("~")
class CopilotPlugin(AgentPlugin): def _json_source(value: dict) -> str:
"""Install/uninstall Elydora audit hook for Copilot CLI.""" return json.dumps(value, indent=2) + "\n"
@staticmethod
def _hook_path_for(agent_id: str) -> str:
return os.path.join(ELYDORA_DIR, agent_id, "hook.py")
def install(self, config: InstallConfig) -> None: def _runtime_config(config: InstallConfig, agent_id: str) -> dict:
agent_id = config.get("agent_id", "") value = {
agent_name = config.get("agent_name", "")
# Create per-agent directory
agent_dir = os.path.join(ELYDORA_DIR, agent_id)
os.makedirs(agent_dir, exist_ok=True)
# Write config.json
config_data = {
"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"),
"token": config.get("token", ""), "agent_name": AGENT_KEY,
"agent_name": agent_name,
} }
config_path = os.path.join(agent_dir, "config.json") token = config.get("token", "")
write_json_atomic( if token:
config_path, value["token"] = token
config_data, return value
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( 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 Elydora into GitHub Copilot CLI's global user hooks."""
def install(self, config: InstallConfig) -> None:
agent_id = config.get("agent_id", "")
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")
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),
]
audit_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"),
) )
hook_path = self._hook_path_for(agent_id) candidates: List[Optional[FileChange]] = [
write_text_atomic( file_change(
hook_path, os.path.join(agent_directory, "config.json"),
script, "Elydora runtime config",
0o700, _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", "Elydora audit runtime",
) audit_script,
0o700,
guard_script_path = config.get("guard_script_path", "") ),
python_exe = sys.executable ]
changes = [change for change in candidates if change is not None]
settings_path = _settings_path() changes.extend(_rendered_changes(sources, user_hooks))
settings = _load_json(settings_path) write_changes(changes, "Install GitHub Copilot hooks")
settings["version"] = 1 print(f"GitHub Copilot CLI hooks: {sources.user.file_path}")
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}")
def uninstall(self, agent_id: str = "") -> None: def uninstall(self, agent_id: str = "") -> None:
settings_path = _settings_path() sources = read_sources(_home_dir())
if os.path.exists(settings_path): user_hooks = remove_managed_hooks(
settings = _load_json(settings_path) sources.user.hooks,
hooks = settings.get("hooks", {}) ELYDORA_DIR,
changed = False agent_id,
)
# Remove preToolUse entries changes = _rendered_changes(sources, user_hooks, agent_id)
pre_tool_use = hooks.get("preToolUse", []) write_changes(changes, "Uninstall GitHub Copilot hooks")
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.")
def status(self) -> PluginStatus: def status(self) -> PluginStatus:
# Scan ~/.elydora/*/hook.py for any installed hook sources = read_sources(_home_dir())
import glob as _glob contracts = _contracts(sources)
hook_pattern = os.path.join(ELYDORA_DIR, "*", "hook.py") if not contracts:
hook_files = _glob.glob(hook_pattern) return PluginStatus(
hook_exists = len(hook_files) > 0 installed=False,
agent=AGENT_KEY,
settings_configured = False details="Not installed",
settings_path = _settings_path() )
if os.path.exists(settings_path): installed = runtime_files_exist(contracts)
settings = _load_json(settings_path) config_path = _configured_path(sources, contracts)
hooks = settings.get("hooks", {}) details = (
pre_tool_use = hooks.get("preToolUse", []) f"Config: {config_path}"
post_tool_use = hooks.get("postToolUse", []) if installed
pre_configured = any(_is_elydora_hook(h) for h in pre_tool_use) else f"Configured at {config_path}; runtime scripts missing"
post_configured = any(_is_elydora_hook(h) for h in post_tool_use) )
settings_configured = pre_configured and post_configured return PluginStatus(
installed=installed,
installed = hook_exists and settings_configured agent=AGENT_KEY,
if installed: details=details,
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")
@@ -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
+96
View File
@@ -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
+3 -3
View File
@@ -27,9 +27,9 @@ SUPPORTED_AGENTS: Dict[str, Dict[str, str]] = {
"config_path": "~/.cline/hooks/PreToolUse.mjs", "config_path": "~/.cline/hooks/PreToolUse.mjs",
}, },
"copilot": { "copilot": {
"name": "Copilot CLI", "name": "GitHub Copilot CLI",
"hook_event": "PostToolUse", "hook_event": "preToolUse/postToolUse",
"config_path": ".github/hooks/hooks.json", "config_path": "~/.copilot/hooks/elydora-audit.json",
}, },
"cursor": { "cursor": {
"name": "Cursor", "name": "Cursor",
+439
View File
@@ -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()
)