277 lines
8.7 KiB
Python
277 lines
8.7 KiB
Python
"""GitHub Copilot CLI hook schema validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import json
|
|
import shutil
|
|
import subprocess # nosec B404 - validates official JavaScript regex syntax
|
|
from typing import Any, Dict, List
|
|
import urllib.parse
|
|
|
|
|
|
JsonObject = Dict[str, Any]
|
|
CopilotHooks = Dict[str, List[JsonObject]]
|
|
|
|
SUPPORTED_EVENTS = frozenset({
|
|
"agentStop",
|
|
"Stop",
|
|
"errorOccurred",
|
|
"ErrorOccurred",
|
|
"notification",
|
|
"Notification",
|
|
"permissionRequest",
|
|
"PermissionRequest",
|
|
"postToolUse",
|
|
"PostToolUse",
|
|
"postToolUseFailure",
|
|
"PostToolUseFailure",
|
|
"preCompact",
|
|
"PreCompact",
|
|
"preToolUse",
|
|
"PreToolUse",
|
|
"sessionEnd",
|
|
"SessionEnd",
|
|
"sessionStart",
|
|
"SessionStart",
|
|
"subagentStart",
|
|
"SubagentStart",
|
|
"subagentStop",
|
|
"SubagentStop",
|
|
"userPromptSubmitted",
|
|
"UserPromptSubmit",
|
|
"userPromptTransformed",
|
|
})
|
|
|
|
_REGEX_TIMEOUT_SECONDS = 10
|
|
_REGEX_VALIDATOR = """import fs from "node:fs";
|
|
const entries = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
for (const entry of entries) {
|
|
try {
|
|
new RegExp("^(?:" + entry.pattern + ")$");
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
process.stderr.write(entry.label + ": " + message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
def _field_label(label: str, field: str) -> str:
|
|
return f'{label} field "{field}"'
|
|
|
|
|
|
def _require_string(value: Any, label: str) -> str:
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError(f"{label} must be a non-empty string")
|
|
return value
|
|
|
|
|
|
def _validate_optional_string(
|
|
handler: JsonObject, field: str, label: str
|
|
) -> None:
|
|
if field in handler and not isinstance(handler[field], str):
|
|
raise ValueError(f"{_field_label(label, field)} must be a string")
|
|
|
|
|
|
def _validate_timeout(handler: JsonObject, label: str) -> None:
|
|
for field in ("timeout", "timeoutSec"):
|
|
if field not in handler:
|
|
continue
|
|
value = handler[field]
|
|
if (
|
|
isinstance(value, bool)
|
|
or not isinstance(value, (int, float))
|
|
or not math.isfinite(value)
|
|
or value <= 0
|
|
):
|
|
raise ValueError(
|
|
f"{_field_label(label, field)} must be a positive number"
|
|
)
|
|
|
|
|
|
def _validate_matcher(handler: JsonObject, event: str, label: str) -> None:
|
|
if "matcher" not in handler:
|
|
return
|
|
matcher = _require_string(
|
|
handler["matcher"], _field_label(label, "matcher")
|
|
)
|
|
if event in (
|
|
"preToolUse",
|
|
"PreToolUse",
|
|
"permissionRequest",
|
|
"PermissionRequest",
|
|
) and matcher in ("*", "**"):
|
|
return
|
|
|
|
|
|
def _validate_string_map(value: Any, label: str) -> None:
|
|
if not isinstance(value, dict):
|
|
raise ValueError(f"{label} must be an object")
|
|
for key, item in value.items():
|
|
if not isinstance(key, str):
|
|
raise ValueError(f"{label} keys must be strings")
|
|
_require_string(item, f"{label}.{key}")
|
|
|
|
|
|
def _validate_command(
|
|
handler: JsonObject, event: str, label: str
|
|
) -> None:
|
|
command_fields = tuple(
|
|
field for field in ("bash", "powershell", "command") if field in handler
|
|
)
|
|
if not command_fields:
|
|
raise ValueError(f"{label} must define bash, powershell, or command")
|
|
for field in ("bash", "powershell", "command", "cwd"):
|
|
_validate_optional_string(handler, field, label)
|
|
if "env" in handler:
|
|
_validate_string_map(handler["env"], _field_label(label, "env"))
|
|
_validate_timeout(handler, label)
|
|
_validate_matcher(handler, event, label)
|
|
|
|
|
|
def _is_loopback(hostname: str) -> bool:
|
|
return hostname in ("localhost", "::1") or hostname.startswith("127.")
|
|
|
|
|
|
def _validate_http(handler: JsonObject, event: str, label: str) -> None:
|
|
raw_url = _require_string(handler.get("url"), _field_label(label, "url"))
|
|
try:
|
|
parsed = urllib.parse.urlsplit(raw_url)
|
|
hostname = parsed.hostname
|
|
parsed.port
|
|
except ValueError as error:
|
|
raise ValueError(f"{_field_label(label, 'url')} is invalid") from error
|
|
localhost_allowed = (
|
|
os.environ.get("COPILOT_HOOK_ALLOW_LOCALHOST") == "1"
|
|
and parsed.scheme == "http"
|
|
and hostname is not None
|
|
and _is_loopback(hostname)
|
|
)
|
|
if (
|
|
(parsed.scheme != "https" and not localhost_allowed)
|
|
or not parsed.netloc
|
|
or hostname is None
|
|
):
|
|
raise ValueError(f"{_field_label(label, 'url')} must use HTTPS")
|
|
if "headers" in handler:
|
|
_validate_string_map(
|
|
handler["headers"], _field_label(label, "headers")
|
|
)
|
|
if "allowedEnvVars" in handler:
|
|
value = handler["allowedEnvVars"]
|
|
if not isinstance(value, list) or any(
|
|
not isinstance(item, str) or not item for item in value
|
|
):
|
|
raise ValueError(
|
|
f"{_field_label(label, 'allowedEnvVars')} must be an array of strings"
|
|
)
|
|
_validate_timeout(handler, label)
|
|
_validate_matcher(handler, event, label)
|
|
|
|
|
|
def _validate_prompt(handler: JsonObject, event: str, label: str) -> None:
|
|
if event not in ("sessionStart", "SessionStart"):
|
|
raise ValueError(
|
|
f"{label} prompt hooks are supported only for sessionStart"
|
|
)
|
|
_require_string(handler.get("prompt"), _field_label(label, "prompt"))
|
|
|
|
|
|
def _validate_handler(handler: JsonObject, event: str, label: str) -> None:
|
|
handler_type = handler.get("type")
|
|
if handler_type is None:
|
|
handler_type = "command"
|
|
if handler_type == "command":
|
|
_validate_command(handler, event, label)
|
|
elif handler_type == "http":
|
|
_validate_http(handler, event, label)
|
|
elif handler_type == "prompt":
|
|
_validate_prompt(handler, event, label)
|
|
else:
|
|
raise ValueError(
|
|
f'{_field_label(label, "type")} is unsupported'
|
|
)
|
|
|
|
|
|
def validate_hooks(value: Any, label: str) -> CopilotHooks:
|
|
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) or event not in SUPPORTED_EVENTS:
|
|
raise ValueError(f'{label} hook event "{event}" is unsupported')
|
|
if not isinstance(handlers, list):
|
|
raise ValueError(f'{label} field "hooks.{event}" must be an array')
|
|
validated = []
|
|
for index, handler in enumerate(handlers):
|
|
item_label = f"{label} handler hooks.{event}[{index}]"
|
|
if not isinstance(handler, dict):
|
|
raise ValueError(f"{item_label} must be an object")
|
|
_validate_handler(handler, event, item_label)
|
|
validated.append(dict(handler))
|
|
hooks[event] = validated
|
|
return hooks
|
|
|
|
|
|
def _matcher_entries(hooks: CopilotHooks) -> List[JsonObject]:
|
|
entries = []
|
|
wildcard_events = {
|
|
"preToolUse",
|
|
"PreToolUse",
|
|
"permissionRequest",
|
|
"PermissionRequest",
|
|
}
|
|
for event, handlers in hooks.items():
|
|
for index, handler in enumerate(handlers):
|
|
matcher = handler.get("matcher")
|
|
if not isinstance(matcher, str):
|
|
continue
|
|
if event in wildcard_events and matcher in ("*", "**"):
|
|
continue
|
|
entries.append({
|
|
"label": f"GitHub Copilot hooks.{event}[{index}] matcher",
|
|
"pattern": matcher,
|
|
})
|
|
return entries
|
|
|
|
|
|
def validate_javascript_regexes(sources: List[CopilotHooks]) -> None:
|
|
entries = [entry for hooks in sources for entry in _matcher_entries(hooks)]
|
|
if not entries:
|
|
return
|
|
node_path = shutil.which("node")
|
|
if node_path is None:
|
|
raise FileNotFoundError(
|
|
"Node.js runtime is required to validate GitHub Copilot hook matchers"
|
|
)
|
|
try:
|
|
result = subprocess.run( # nosec B603
|
|
[node_path, "--input-type=module", "--eval", _REGEX_VALIDATOR],
|
|
input=json.dumps(entries),
|
|
text=True,
|
|
capture_output=True,
|
|
check=False,
|
|
timeout=_REGEX_TIMEOUT_SECONDS,
|
|
)
|
|
except subprocess.TimeoutExpired as error:
|
|
raise TimeoutError(
|
|
"GitHub Copilot hook matcher validation timed out after "
|
|
f"{_REGEX_TIMEOUT_SECONDS} seconds"
|
|
) from error
|
|
except OSError as error:
|
|
raise OSError(f"Run Node.js Copilot matcher validator: {error}") from error
|
|
if result.returncode == 0:
|
|
return
|
|
message = (
|
|
result.stderr.strip()
|
|
or result.stdout.strip()
|
|
or f"Node.js exited with code {result.returncode}"
|
|
)
|
|
raise ValueError(
|
|
"GitHub Copilot matcher must be a valid JavaScript regular expression: "
|
|
f"{message}"
|
|
)
|