fix(copilot): mirror transactional Python hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 12:34:09 -04:00
1 parent af8d227b45
commit 9b67aad113
13 files changed
+2120 -583

No files matched your search

@@ -0,0 +1,276 @@
"""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}"
)