Files
Elydora-Go-SDK/cmd/elydora/plugins/copilot_schema.go
T

352 lines
8.8 KiB
Go

package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"net/url"
"os"
"os/exec"
"strings"
"time"
)
const copilotRegexValidationTimeout = 10 * time.Second
var copilotSupportedEvents = stringSet(
"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",
)
const copilotRegexValidator = `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);
}
}
`
type copilotMatcherEntry struct {
Label string `json:"label"`
Pattern string `json:"pattern"`
}
func copilotFieldLabel(label, field string) string {
return fmt.Sprintf(`%s field %q`, label, field)
}
func requireCopilotString(value any, label string) (string, error) {
text, ok := value.(string)
if !ok || text == "" {
return "", fmt.Errorf("%s must be a non-empty string", label)
}
return text, nil
}
func validateCopilotOptionalString(
handler map[string]any,
field string,
label string,
) error {
value, exists := handler[field]
if exists {
if _, ok := value.(string); !ok {
return fmt.Errorf("%s must be a string", copilotFieldLabel(label, field))
}
}
return nil
}
func validateCopilotTimeouts(handler map[string]any, label string) error {
for _, field := range []string{"timeout", "timeoutSec"} {
value, exists := handler[field]
if !exists {
continue
}
number, ok := value.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return fmt.Errorf(
"%s must be a positive number",
copilotFieldLabel(label, field),
)
}
}
return nil
}
func validateCopilotMatcher(
handler map[string]any,
label string,
) error {
value, exists := handler["matcher"]
if !exists {
return nil
}
_, err := requireCopilotString(value, copilotFieldLabel(label, "matcher"))
return err
}
func validateCopilotStringMap(value any, label string) error {
object, ok := value.(map[string]any)
if !ok || object == nil {
return fmt.Errorf("%s must be an object", label)
}
for key, item := range object {
if _, err := requireCopilotString(item, label+"."+key); err != nil {
return err
}
}
return nil
}
func validateCopilotCommand(
handler map[string]any,
label string,
) error {
hasCommand := false
for _, field := range []string{"bash", "powershell", "command"} {
if _, exists := handler[field]; exists {
hasCommand = true
}
}
if !hasCommand {
return fmt.Errorf("%s must define bash, powershell, or command", label)
}
for _, field := range []string{"bash", "powershell", "command", "cwd"} {
if err := validateCopilotOptionalString(handler, field, label); err != nil {
return err
}
}
if value, exists := handler["env"]; exists {
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "env")); err != nil {
return err
}
}
if err := validateCopilotTimeouts(handler, label); err != nil {
return err
}
return validateCopilotMatcher(handler, label)
}
func copilotLoopbackHost(hostname string) bool {
return hostname == "localhost" || hostname == "::1" ||
strings.HasPrefix(hostname, "127.")
}
func validateCopilotHTTP(
handler map[string]any,
label string,
) error {
rawURL, err := requireCopilotString(
handler["url"],
copilotFieldLabel(label, "url"),
)
if err != nil {
return err
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || parsed.Hostname() == "" {
return fmt.Errorf("%s is invalid", copilotFieldLabel(label, "url"))
}
localhostAllowed := os.Getenv("COPILOT_HOOK_ALLOW_LOCALHOST") == "1" &&
parsed.Scheme == "http" && copilotLoopbackHost(parsed.Hostname())
if parsed.Scheme != "https" && !localhostAllowed {
return fmt.Errorf("%s must use HTTPS", copilotFieldLabel(label, "url"))
}
if value, exists := handler["headers"]; exists {
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "headers")); err != nil {
return err
}
}
if value, exists := handler["allowedEnvVars"]; exists {
items, ok := value.([]any)
if !ok {
return fmt.Errorf(
"%s must be an array of strings",
copilotFieldLabel(label, "allowedEnvVars"),
)
}
for _, item := range items {
if text, ok := item.(string); !ok || text == "" {
return fmt.Errorf(
"%s must be an array of strings",
copilotFieldLabel(label, "allowedEnvVars"),
)
}
}
}
if err := validateCopilotTimeouts(handler, label); err != nil {
return err
}
return validateCopilotMatcher(handler, label)
}
func validateCopilotPrompt(
handler map[string]any,
event string,
label string,
) error {
if event != "sessionStart" && event != "SessionStart" {
return fmt.Errorf("%s prompt hooks are supported only for sessionStart", label)
}
_, err := requireCopilotString(handler["prompt"], copilotFieldLabel(label, "prompt"))
return err
}
func validateCopilotHandler(
handler map[string]any,
event string,
label string,
) error {
handlerType := "command"
if value, exists := handler["type"]; exists {
var ok bool
handlerType, ok = value.(string)
if !ok {
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
}
}
switch handlerType {
case "command":
return validateCopilotCommand(handler, label)
case "http":
return validateCopilotHTTP(handler, label)
case "prompt":
return validateCopilotPrompt(handler, event, label)
default:
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
}
}
func validateCopilotHooks(value any, label string) (copilotHooks, error) {
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
}
hooks := make(copilotHooks, len(object))
for event, candidate := range object {
if _, supported := copilotSupportedEvents[event]; !supported {
return nil, fmt.Errorf("%s hook event %q is unsupported", label, event)
}
values, ok := candidate.([]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
}
handlers := make([]map[string]any, 0, len(values))
for index, value := range values {
handler, ok := value.(map[string]any)
itemLabel := fmt.Sprintf("%s handler hooks.%s[%d]", label, event, index)
if !ok || handler == nil {
return nil, fmt.Errorf("%s must be an object", itemLabel)
}
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
return nil, err
}
handlers = append(handlers, cloneCopilotObject(handler))
}
hooks[event] = handlers
}
return hooks, nil
}
func copilotMatcherEntries(hooks copilotHooks) []copilotMatcherEntry {
entries := make([]copilotMatcherEntry, 0)
wildcardEvents := stringSet(
"preToolUse", "PreToolUse", "permissionRequest", "PermissionRequest",
)
for event, handlers := range hooks {
for index, handler := range handlers {
matcher, ok := handler["matcher"].(string)
if !ok {
continue
}
if _, wildcardEvent := wildcardEvents[event]; wildcardEvent &&
(matcher == "*" || matcher == "**") {
continue
}
entries = append(entries, copilotMatcherEntry{
Label: fmt.Sprintf("GitHub Copilot hooks.%s[%d] matcher", event, index),
Pattern: matcher,
})
}
}
return entries
}
func validateCopilotJavaScriptMatchers(
sources []copilotHooks,
nodePath string,
) error {
entries := make([]copilotMatcherEntry, 0)
for _, hooks := range sources {
entries = append(entries, copilotMatcherEntries(hooks)...)
}
if len(entries) == 0 {
return nil
}
input, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("encode GitHub Copilot matchers: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), copilotRegexValidationTimeout)
defer cancel()
command := exec.CommandContext( // #nosec G204 -- nodePath is resolved through exec.LookPath.
ctx,
nodePath,
"--input-type=module",
"--eval",
copilotRegexValidator,
)
command.Stdin = bytes.NewReader(input)
output, runErr := command.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf(
"GitHub Copilot matcher validation timed out after %s",
copilotRegexValidationTimeout,
)
}
if runErr != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = runErr.Error()
}
return fmt.Errorf(
"GitHub Copilot matcher must be a valid JavaScript regular expression: %s",
message,
)
}
return nil
}