diff --git a/AGENTS.md b/AGENTS.md index 6b5ca8e..0571a1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control. +- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction. - Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review. - Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files. - Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for review. diff --git a/README.md b/README.md index a6279c3..9829299 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ Credential options may be omitted in an interactive terminal; the CLI then reads Codex performs a one-time trust review for user hooks. Run `/hooks` in Codex after installation and trust the Elydora `PreToolUse` and `PostToolUse` definitions. +GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false. + Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. @@ -112,7 +114,7 @@ Qwen Code installation writes native user hooks to `$QWEN_HOME/settings.json` wi | Claude Code | `claudecode` | | OpenAI Codex | `codex` | | Cline | `cline` | -| Copilot CLI | `copilot` | +| GitHub Copilot CLI | `copilot` | | Cursor | `cursor` | | Factory Droid | `droid` | | Gemini CLI | `gemini` | diff --git a/cmd/elydora/plugins/copilot.go b/cmd/elydora/plugins/copilot.go index ae5cad6..1243705 100644 --- a/cmd/elydora/plugins/copilot.go +++ b/cmd/elydora/plugins/copilot.go @@ -4,258 +4,162 @@ import ( "fmt" "os" "path/filepath" - "strings" ) -// CopilotPlugin manages the Elydora audit hook for GitHub Copilot CLI. -// It writes/merges into .github/hooks/hooks.json (project-relative) using -// hooks.preToolUse[]/postToolUse[] with bash/powershell command fields. -type CopilotPlugin struct{} - -func (p *CopilotPlugin) configPath() (string, error) { - cwd, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("resolve working directory: %w", err) - } - return filepath.Join(cwd, ".github", "hooks", "hooks.json"), nil +// CopilotPlugin manages GitHub Copilot CLI native user hooks. +type CopilotPlugin struct { + rename renameFunc } func (p *CopilotPlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath(config.AgentID) + if config.AgentID == "" { + return fmt.Errorf("agent ID is required") + } + sources, err := readCopilotSources() if err != nil { return err } - if config.HookScript != "" { - scriptPath = config.HookScript + if sources.user.disabled { + return fmt.Errorf( + "GitHub Copilot user hooks are disabled by disableAllHooks at %s", + sources.user.filePath, + ) } - - if err := GenerateHookScript(scriptPath, config); err != nil { - return fmt.Errorf("generate hook script: %w", err) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return err } - - guardPath := config.GuardScriptPath - if guardPath == "" { - guardPath, err = guardScriptPath(config.AgentID) - if err != nil { - return err - } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return err } - - configPath, err := p.configPath() + expectedGuard := filepath.Join(agentDirectory, copilotGuardScript) + if !sameCopilotPath(config.GuardScriptPath, expectedGuard) { + return fmt.Errorf("Elydora guard runtime must use the managed agent directory: %s", expectedGuard) + } + if err := requireCopilotRuntime(expectedGuard, "Elydora guard runtime"); err != nil { + return err + } + auditPath := filepath.Join(agentDirectory, copilotAuditScript) + if config.HookScript != "" && !sameCopilotPath(config.HookScript, auditPath) { + return fmt.Errorf("Elydora audit runtime must use the managed agent directory: %s", auditPath) + } + nodePath, err := resolveNodeRuntime() if err != nil { return err } - settings, err := readJSONFile(configPath) + userHooks := removeManagedCopilotHooks(sources.user.hooks, runtimeRoot, "") + userHooks["preToolUse"] = append( + userHooks["preToolUse"], + buildCopilotHandler(nodePath, expectedGuard), + ) + userHooks["postToolUse"] = append( + userHooks["postToolUse"], + buildCopilotHandler(nodePath, auditPath), + ) + userRendered, err := renderCopilotDocument(sources.user, userHooks) + if err != nil { + return fmt.Errorf("render GitHub Copilot user hooks: %w", err) + } + rendered := []*copilotRenderedDocument{userRendered} + if sources.legacy != nil { + legacyHooks := removeManagedCopilotHooks(sources.legacy.hooks, runtimeRoot, "") + legacyRendered, renderErr := renderCopilotDocument(sources.legacy, legacyHooks) + if renderErr != nil { + return fmt.Errorf("render GitHub Copilot project hooks: %w", renderErr) + } + rendered = append(rendered, legacyRendered) + } + changes, err := prepareCopilotInstallationChanges( + config, + agentDirectory, + auditPath, + rendered, + ) if err != nil { return err } - - // Ensure version field is set - settings["version"] = float64(1) - - // Ensure hooks object exists - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - hooks = make(map[string]interface{}) - } - - // --- preToolUse (guard — freeze enforcement) --- - preToolUse, _ := hooks["preToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isCopilotElydoraEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) - } - guardEntry := map[string]interface{}{ - "type": "command", - "bash": "node " + guardPath, - "powershell": "node " + guardPath, - "timeoutSec": float64(5), - } - preFiltered = append(preFiltered, guardEntry) - hooks["preToolUse"] = preFiltered - - // --- postToolUse (audit logging) --- - postToolUse, _ := hooks["postToolUse"].([]interface{}) - var postFiltered []interface{} - for _, entry := range postToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isCopilotElydoraEntry(m) { - continue - } - } - postFiltered = append(postFiltered, entry) - } - hookEntry := map[string]interface{}{ - "type": "command", - "bash": "node " + scriptPath, - "powershell": "node " + scriptPath, - "timeoutSec": float64(5), - } - postFiltered = append(postFiltered, hookEntry) - hooks["postToolUse"] = postFiltered - - settings["hooks"] = hooks - - if err := writeJSONFile(configPath, settings); err != nil { + if err := writeChanges(changes, "Install GitHub Copilot hooks", p.rename); err != nil { return err } - fmt.Printf("Installed Elydora hook for Copilot CLI at %s\n", configPath) + fmt.Printf(" GitHub Copilot CLI hooks: %s\n", sources.user.filePath) return nil } func (p *CopilotPlugin) Uninstall(agentID string) error { - configPath, err := p.configPath() + sources, err := readCopilotSources() if err != nil { return err } - - settings, err := readJSONFile(configPath) + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - fmt.Println("No Copilot CLI hooks found.") - return nil + documents := []*copilotDocument{sources.user} + if sources.legacy != nil { + documents = append(documents, sources.legacy) } - - // Remove preToolUse Elydora entries - preToolUse, _ := hooks["preToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isCopilotElydoraEntry(m) { - continue - } + changes := make([]*fileChange, 0, len(documents)) + for _, document := range documents { + hooks := removeManagedCopilotHooks(document.hooks, runtimeRoot, agentID) + rendered, renderErr := renderCopilotDocument(document, hooks) + if renderErr != nil { + return fmt.Errorf("render GitHub Copilot hook source: %w", renderErr) } - preFiltered = append(preFiltered, entry) - } - if len(preFiltered) == 0 { - delete(hooks, "preToolUse") - } else { - hooks["preToolUse"] = preFiltered - } - - // Remove postToolUse Elydora entries - postToolUse, _ := hooks["postToolUse"].([]interface{}) - var postFiltered []interface{} - for _, entry := range postToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isCopilotElydoraEntry(m) { - continue - } + change, changeErr := prepareRenderedCopilotChange(rendered) + if changeErr != nil { + return changeErr } - postFiltered = append(postFiltered, entry) + changes = append(changes, change) } - if len(postFiltered) == 0 { - delete(hooks, "postToolUse") - } else { - hooks["postToolUse"] = postFiltered - } - - if len(hooks) == 0 { - delete(settings, "hooks") - } else { - settings["hooks"] = hooks - } - - if err := writeJSONFile(configPath, settings); err != nil { - return err - } - - if agentID != "" { - scriptPath, _ := hookScriptPath(agentID) - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath(agentID) - if gPath != "" { - os.Remove(gPath) - } - } - fmt.Println("Uninstalled Elydora hook for Copilot CLI.") - return nil + return writeChanges(changes, "Uninstall GitHub Copilot hooks", p.rename) } func (p *CopilotPlugin) Status() (PluginStatus, error) { - configPath, err := p.configPath() - if err != nil { - return PluginStatus{}, err - } - + userPath, _, pathErr := copilotConfigPaths() + entry := SupportedAgents[copilotAgentKey] status := PluginStatus{ - AgentName: "copilot", - DisplayName: "Copilot CLI", - ConfigPath: configPath, + AgentName: copilotAgentKey, DisplayName: entry.Name, ConfigPath: userPath, } - - settings, err := readJSONFile(configPath) + if pathErr != nil { + return status, pathErr + } + sources, err := readCopilotSources() if err != nil { + return status, err + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return status, err + } + contracts := activeCopilotContracts(sources, runtimeRoot) + status.ConfigPath = configuredCopilotPath(sources, runtimeRoot) + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { return status, nil } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks != nil { - preConfigured := hasCopilotElydoraEntry(hooks["preToolUse"]) - postConfigured := hasCopilotElydoraEntry(hooks["postToolUse"]) - status.HookConfigured = preConfigured && postConfigured - - // Extract hook script path from the configured command - scriptPath := extractCopilotElydoraScriptPath(hooks["postToolUse"]) - if scriptPath != "" { - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - } + status.HookScriptExists, err = copilotRuntimeFilesExist(contracts) + if err != nil { + return status, err } - - status.Installed = status.HookConfigured && status.HookScriptExists + status.Installed = status.HookScriptExists return status, nil } -// isCopilotElydoraEntry checks if a Copilot hook entry (bash/powershell fields) is an Elydora hook. -func isCopilotElydoraEntry(m map[string]interface{}) bool { - if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") { - return true +func requireCopilotRuntime(path, label string) error { + if path == "" { + return fmt.Errorf("%s path is required", label) } - if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") { - return true + info, err := os.Lstat(path) + if os.IsNotExist(err) { + return fmt.Errorf("%s is missing: %s", label, path) } - return false -} - -// hasCopilotElydoraEntry checks if a Copilot hook array contains an Elydora entry. -func hasCopilotElydoraEntry(hookArray interface{}) bool { - arr, _ := hookArray.([]interface{}) - for _, entry := range arr { - if m, ok := entry.(map[string]interface{}); ok { - if isCopilotElydoraEntry(m) { - return true - } - } + if err != nil { + return fmt.Errorf("read %s at %s: %w", label, path, err) } - return false -} - -// extractCopilotElydoraScriptPath extracts the script path from a Copilot hook array's Elydora command. -func extractCopilotElydoraScriptPath(hookArray interface{}) string { - arr, _ := hookArray.([]interface{}) - for _, entry := range arr { - if m, ok := entry.(map[string]interface{}); ok { - if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") { - return extractPathFromNodeCommand(bash) - } - if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") { - return extractPathFromNodeCommand(ps) - } - } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return fmt.Errorf("%s path is not a physical file: %s", label, path) } - return "" + return nil } diff --git a/cmd/elydora/plugins/copilot_contract.go b/cmd/elydora/plugins/copilot_contract.go new file mode 100644 index 0000000..9607121 --- /dev/null +++ b/cmd/elydora/plugins/copilot_contract.go @@ -0,0 +1,330 @@ +package plugins + +import ( + "encoding/json" + "path/filepath" + "runtime" + "sort" + "strings" +) + +const ( + copilotAgentKey = "copilot" + copilotGuardScript = "guard.js" + copilotAuditScript = "hook.js" + copilotConfigFile = "elydora-audit.json" + copilotHookTimeout = float64(10) + copilotLegacyTimeout = float64(5) + copilotPOSIXApostrophe = `'"'"'` +) + +type copilotHooks map[string][]map[string]any + +type copilotDocument struct { + exists bool + filePath string + root map[string]any + hooks copilotHooks + raw []byte + disabled bool +} + +type copilotSources struct { + user *copilotDocument + legacy *copilotDocument +} + +type copilotRenderedDocument struct { + document *copilotDocument + changed bool + next []byte + remove bool +} + +type copilotRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +func cloneCopilotHooks(source copilotHooks) copilotHooks { + clone := make(copilotHooks, len(source)) + for event, handlers := range source { + clone[event] = append([]map[string]any(nil), handlers...) + } + return clone +} + +func sameCopilotPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if absolute, err := filepath.Abs(left); err == nil { + left = absolute + } + if absolute, err := filepath.Abs(right); err == nil { + right = absolute + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameCopilotAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func quoteCopilotPowerShell(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + +func buildCopilotHandler(nodePath, scriptPath string) map[string]any { + return map[string]any{ + "type": "command", + "bash": quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath), + "powershell": "& " + quoteCopilotPowerShell(nodePath) + " " + quoteCopilotPowerShell(scriptPath) + "; exit $LASTEXITCODE", + "timeoutSec": copilotHookTimeout, + } +} + +func readCopilotPOSIXArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); { + if strings.HasPrefix(command[index:], copilotPOSIXApostrophe) { + value.WriteByte('\'') + index += len(copilotPOSIXApostrophe) + continue + } + if command[index] == '\'' { + return value.String(), index + 1, true + } + value.WriteByte(command[index]) + index++ + } + return "", start, false +} + +func readCopilotPowerShellArgument(command string, start int) (string, int, bool) { + if start >= len(command) || command[start] != '\'' { + return "", start, false + } + var value strings.Builder + for index := start + 1; index < len(command); index++ { + if command[index] != '\'' { + value.WriteByte(command[index]) + continue + } + if index+1 < len(command) && command[index+1] == '\'' { + value.WriteByte('\'') + index++ + continue + } + return value.String(), index + 1, true + } + return "", start, false +} + +func parseCopilotBash(command string) (string, string, bool) { + executable, next, ok := readCopilotPOSIXArgument(command, 0) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + script, end, ok := readCopilotPOSIXArgument(command, next+1) + return executable, script, ok && end == len(command) +} + +func parseCopilotPowerShell(command string) (string, string, bool) { + if !strings.HasPrefix(command, "& ") { + return "", "", false + } + executable, next, ok := readCopilotPowerShellArgument(command, 2) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + script, end, ok := readCopilotPowerShellArgument(command, next+1) + return executable, script, ok && command[end:] == "; exit $LASTEXITCODE" +} + +func parseCopilotLegacyCommand(command string) (string, bool) { + command = strings.TrimSpace(command) + separator := strings.IndexAny(command, " \t") + if separator < 0 { + return "", false + } + executable := command[:separator] + if !strings.EqualFold(executable, "node") && !strings.EqualFold(executable, "node.exe") { + return "", false + } + script := strings.TrimSpace(command[separator:]) + if len(script) >= 2 && ((script[0] == '"' && script[len(script)-1] == '"') || + (script[0] == '\'' && script[len(script)-1] == '\'')) { + script = script[1 : len(script)-1] + } + return script, script != "" +} + +func isCopilotNodeExecutable(path string) bool { + name := filepath.Base(path) + return name == "node" || strings.EqualFold(name, "node.exe") +} + +func copilotManagedScriptPath(handler map[string]any) (string, bool) { + if len(handler) != 4 || handler["type"] != "command" { + return "", false + } + bash, bashOK := handler["bash"].(string) + powershell, powershellOK := handler["powershell"].(string) + if !bashOK || !powershellOK { + return "", false + } + if handler["timeoutSec"] == copilotHookTimeout { + bashNode, bashScript, bashParsed := parseCopilotBash(bash) + psNode, psScript, psParsed := parseCopilotPowerShell(powershell) + if bashParsed && psParsed && filepath.IsAbs(bashNode) && filepath.IsAbs(bashScript) && + isCopilotNodeExecutable(bashNode) && sameCopilotPath(bashNode, psNode) && + sameCopilotPath(bashScript, psScript) { + return bashScript, true + } + } + if handler["timeoutSec"] != copilotLegacyTimeout || bash != powershell { + return "", false + } + return parseCopilotLegacyCommand(bash) +} + +func copilotManagedAgentID( + handler map[string]any, + scriptName string, + runtimeRoot string, +) (string, bool) { + scriptPath, managed := copilotManagedScriptPath(handler) + if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName { + return "", false + } + agentDirectory := filepath.Dir(scriptPath) + if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) { + return "", false + } + agentID := filepath.Base(agentDirectory) + return agentID, agentID != "" && agentID != "." && agentID != ".." +} + +func removeManagedCopilotHooks( + hooks copilotHooks, + runtimeRoot string, + agentID string, +) copilotHooks { + result := cloneCopilotHooks(hooks) + for _, contract := range []struct{ event, script string }{ + {"preToolUse", copilotGuardScript}, + {"postToolUse", copilotAuditScript}, + } { + kept := make([]map[string]any, 0, len(result[contract.event])) + for _, handler := range result[contract.event] { + managedID, managed := copilotManagedAgentID(handler, contract.script, runtimeRoot) + if managed && (agentID == "" || sameCopilotAgentID(managedID, agentID)) { + continue + } + kept = append(kept, handler) + } + if len(kept) == 0 { + delete(result, contract.event) + } else { + result[contract.event] = kept + } + } + return result +} + +func renderCopilotDocument( + document *copilotDocument, + hooks copilotHooks, +) (*copilotRenderedDocument, error) { + if !document.exists && len(hooks) == 0 { + return &copilotRenderedDocument{document: document}, nil + } + if document.exists && len(hooks) == 0 { + owned := true + for key := range document.root { + if key != "version" && key != "hooks" { + owned = false + break + } + } + if owned { + return &copilotRenderedDocument{document: document, changed: true, remove: true}, nil + } + } + root := make(map[string]any, len(document.root)+1) + for key, value := range document.root { + root[key] = value + } + root["version"] = float64(1) + if len(hooks) == 0 { + delete(root, "hooks") + } else { + root["hooks"] = hooks + } + next, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, err + } + next = append(next, '\n') + return &copilotRenderedDocument{ + document: document, + changed: !document.exists || string(next) != string(document.raw), + next: next, + }, nil +} + +func copilotRuntimeContracts( + hooks copilotHooks, + runtimeRoot string, +) []copilotRuntimeContract { + guards := managedCopilotIDs(hooks["preToolUse"], copilotGuardScript, runtimeRoot) + audits := managedCopilotIDs(hooks["postToolUse"], copilotAuditScript, runtimeRoot) + keys := make([]string, 0, len(guards)) + for key := range guards { + if _, exists := audits[key]; exists { + keys = append(keys, key) + } + } + sort.Strings(keys) + contracts := make([]copilotRuntimeContract, 0, len(keys)) + for _, key := range keys { + agentID := guards[key] + agentDirectory := filepath.Join(runtimeRoot, agentID) + contracts = append(contracts, copilotRuntimeContract{ + agentID: agentID, + guardPath: filepath.Join(agentDirectory, copilotGuardScript), + auditPath: filepath.Join(agentDirectory, copilotAuditScript), + }) + } + return contracts +} + +func managedCopilotIDs( + handlers []map[string]any, + scriptName string, + runtimeRoot string, +) map[string]string { + result := map[string]string{} + for _, handler := range handlers { + agentID, managed := copilotManagedAgentID(handler, scriptName, runtimeRoot) + if !managed { + continue + } + key := agentID + if runtime.GOOS == "windows" { + key = strings.ToLower(agentID) + } + result[key] = agentID + } + return result +} diff --git a/cmd/elydora/plugins/copilot_io.go b/cmd/elydora/plugins/copilot_io.go new file mode 100644 index 0000000..2111952 --- /dev/null +++ b/cmd/elydora/plugins/copilot_io.go @@ -0,0 +1,309 @@ +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" +) + +func copilotConfigPaths() (string, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("resolve home directory: %w", err) + } + copilotHome := strings.TrimSpace(os.Getenv("COPILOT_HOME")) + if copilotHome == "" { + copilotHome = filepath.Join(home, ".copilot") + } else if !filepath.IsAbs(copilotHome) { + copilotHome, err = filepath.Abs(copilotHome) + if err != nil { + return "", "", fmt.Errorf("resolve COPILOT_HOME: %w", err) + } + } + workingDirectory, err := os.Getwd() + if err != nil { + return "", "", fmt.Errorf("resolve working directory: %w", err) + } + return filepath.Join(copilotHome, "hooks", copilotConfigFile), + filepath.Join(workingDirectory, ".github", "hooks", "hooks.json"), nil +} + +func readCopilotHooks(value any, label string) (copilotHooks, error) { + if value == nil { + return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) + } + object, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) + } + hooks := make(copilotHooks, len(object)) + for event, handlerValue := range object { + values, ok := handlerValue.([]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) + if !ok || handler == nil { + return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index) + } + handlers = append(handlers, handler) + } + hooks[event] = handlers + } + return hooks, nil +} + +func parseCopilotDocument( + filePath string, + raw []byte, + label string, +) (*copilotDocument, error) { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("parse %s at %s: %w", label, filePath, err) + } + root, ok := value.(map[string]any) + if !ok || root == nil { + return nil, fmt.Errorf("%s at %s must contain a JSON object", label, filePath) + } + if root["version"] != float64(1) { + return nil, fmt.Errorf("%s at %s must declare version 1", label, filePath) + } + hooks := copilotHooks{} + if hooksValue, exists := root["hooks"]; exists { + var err error + hooks, err = readCopilotHooks(hooksValue, label) + if err != nil { + return nil, err + } + } + disabled := false + if value, exists := root["disableAllHooks"]; exists { + var valid bool + disabled, valid = value.(bool) + if !valid { + return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label) + } + } + return &copilotDocument{ + exists: true, filePath: filePath, root: root, + hooks: hooks, raw: append([]byte(nil), raw...), disabled: disabled, + }, nil +} + +func readCopilotDocument(filePath, label string) (*copilotDocument, error) { + info, err := os.Lstat(filePath) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("inspect %s at %s: %w", label, filePath, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s path is not a physical file: %s", label, filePath) + } + raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is a validated provider configuration path. + if err != nil { + return nil, fmt.Errorf("read %s at %s: %w", label, filePath, err) + } + return parseCopilotDocument(filePath, raw, label) +} + +func readCopilotSources() (*copilotSources, error) { + userPath, legacyPath, err := copilotConfigPaths() + if err != nil { + return nil, err + } + user, err := readCopilotDocument(userPath, "GitHub Copilot user hooks") + if err != nil { + return nil, err + } + legacy, err := readCopilotDocument(legacyPath, "GitHub Copilot project hooks") + if err != nil { + return nil, err + } + if user == nil { + user = &copilotDocument{ + filePath: userPath, + root: map[string]any{}, + hooks: copilotHooks{}, + } + } + return &copilotSources{user: user, legacy: legacy}, nil +} + +func prepareRenderedCopilotChange( + rendered *copilotRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSourceChange( + rendered.document.filePath, + "GitHub Copilot hook source", + rendered.document.raw, + rendered.document.exists, + rendered.next, + 0600, + rendered.remove, + ) +} + +func prepareCopilotInstallationChanges( + config InstallConfig, + agentDirectory string, + auditPath string, + rendered []*copilotRenderedDocument, +) ([]*fileChange, error) { + baseURL := config.BaseURL + if baseURL == "" { + baseURL = "https://api.elydora.com" + } + runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{ + OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, + BaseURL: baseURL, Token: config.Token, AgentName: copilotAgentKey, + }, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + runtimeConfig = append(runtimeConfig, '\n') + if len(runtimeConfig) > maxRuntimeConfigBytes { + return nil, fmt.Errorf( + "Elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, + {auditPath, "Elydora audit runtime", []byte(buildHookScript(copilotAgentKey, config.AgentID)), 0700}, + } + changes := make([]*fileChange, 0, len(items)+len(rendered)) + for _, item := range items { + if err := validateRuntimeFileTarget(item.path, item.label); err != nil { + return nil, err + } + change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + for _, document := range rendered { + change, err := prepareRenderedCopilotChange(document) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + return changes, nil +} + +func activeCopilotContracts( + sources *copilotSources, + runtimeRoot string, +) []copilotRuntimeContract { + contracts := make([]copilotRuntimeContract, 0) + for _, document := range []*copilotDocument{sources.user, sources.legacy} { + if document == nil || document.disabled { + continue + } + contracts = append(contracts, copilotRuntimeContracts(document.hooks, runtimeRoot)...) + } + unique := map[string]copilotRuntimeContract{} + for _, contract := range contracts { + key := contract.agentID + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + unique[key] = contract + } + result := make([]copilotRuntimeContract, 0, len(unique)) + for _, contract := range unique { + result = append(result, contract) + } + return result +} + +func configuredCopilotPath( + sources *copilotSources, + runtimeRoot string, +) string { + if !sources.user.disabled && len(copilotRuntimeContracts(sources.user.hooks, runtimeRoot)) > 0 { + return sources.user.filePath + } + if sources.legacy != nil && !sources.legacy.disabled && + len(copilotRuntimeContracts(sources.legacy.hooks, runtimeRoot)) > 0 { + return sources.legacy.filePath + } + return sources.user.filePath +} + +func copilotPhysicalFileExists(path, label string) (bool, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect %s at %s: %w", label, path, err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { + return false, fmt.Errorf("%s path is not a physical file: %s", label, path) + } + return true, nil +} + +func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) { + for _, contract := range contracts { + configPath := filepath.Join(filepath.Dir(contract.guardPath), "config.json") + configExists, err := copilotPhysicalFileExists(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !configExists { + continue + } + raw, exists, err := readOptionalFile(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !exists { + continue + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return false, fmt.Errorf("parse Elydora runtime config at %s: %w", configPath, err) + } + config, ok := value.(map[string]any) + if !ok || config == nil { + return false, fmt.Errorf("Elydora runtime config at %s must contain a JSON object", configPath) + } + agentID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != copilotAgentKey || + !sameCopilotAgentID(agentID, contract.agentID) { + continue + } + guardExists, err := copilotPhysicalFileExists(contract.guardPath, "Elydora guard runtime") + if err != nil { + return false, err + } + auditExists, err := copilotPhysicalFileExists(contract.auditPath, "Elydora audit runtime") + if err != nil { + return false, err + } + if guardExists && auditExists { + return true, nil + } + } + return false, nil +} diff --git a/cmd/elydora/plugins/copilot_recovery_test.go b/cmd/elydora/plugins/copilot_recovery_test.go new file mode 100644 index 0000000..c4ee9eb --- /dev/null +++ b/cmd/elydora/plugins/copilot_recovery_test.go @@ -0,0 +1,287 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCopilotInstallRejectsInvalidUserSourcesBeforeRuntimeWrites(t *testing.T) { + tests := map[string]string{ + "malformed JSON": "{", + "array root": "[]\n", + "missing version": `{"hooks":{}}`, + "unsupported version": `{"version":2,"hooks":{}}`, + "null hooks": `{"version":1,"hooks":null}`, + "null event": `{"version":1,"hooks":{"preToolUse":null}}`, + "null handler": `{"version":1,"hooks":{"preToolUse":[null]}}`, + "invalid disable flag": `{"version":1,"disableAllHooks":"false",` + + `"hooks":{}}`, + } + for name, source := range tests { + t.Run(name, func(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + userRaw: copilotString(source), + }) + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted an invalid GitHub Copilot user source") + } + assertCopilotFileEquals(t, fixture.configPath, source) + assertCopilotRuntimeAbsent(t, fixture) + }) + } +} + +func TestCopilotInstallRejectsDisabledUserSource(t *testing.T) { + source := `{"version":1,"disableAllHooks":true,"hooks":{}}` + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + userRaw: copilotString(source), + }) + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "disableAllHooks") { + t.Fatalf("install error = %v", err) + } + assertCopilotFileEquals(t, fixture.configPath, source) + assertCopilotRuntimeAbsent(t, fixture) +} + +func TestCopilotInstallRejectsInvalidProjectSourceBeforeWrites(t *testing.T) { + source := `{"version":1,"hooks":{"preToolUse":[null]}}` + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + legacyRaw: copilotString(source), + }) + + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted an invalid GitHub Copilot project source") + } + assertCopilotFileEquals(t, fixture.legacyPath, source) + if _, err := os.Stat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("user hook source exists after rejection: %v", err) + } + assertCopilotRuntimeAbsent(t, fixture) +} + +func TestCopilotInstallRejectsMissingOrUnmanagedGuardBeforeWrites(t *testing.T) { + t.Run("missing", func(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted a missing guard runtime") + } + assertCopilotRuntimeAbsent(t, fixture) + }) + t.Run("unmanaged path", func(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + unmanaged := filepath.Join(fixture.homeDir, "unmanaged-guard.js") + if err := os.WriteFile(unmanaged, []byte("process.exit(2);\n"), 0700); err != nil { + t.Fatalf("write unmanaged guard: %v", err) + } + fixture.config.GuardScriptPath = unmanaged + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted an unmanaged guard runtime") + } + assertCopilotRuntimeAbsent(t, fixture) + }) +} + +func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) { + t.Run("guard runtime", func(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) + target := filepath.Join(fixture.homeDir, "guard-target.js") + if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil { + t.Fatalf("write guard target: %v", err) + } + if err := os.Symlink(target, fixture.guardPath); err != nil { + t.Skipf("create guard symlink: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted a symlink guard runtime") + } + assertCopilotRuntimeAbsent(t, fixture) + }) + t.Run("user source", func(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + target := filepath.Join(fixture.homeDir, "copilot-target.json") + writeCopilotObject(t, target, map[string]any{"version": float64(1), "hooks": map[string]any{}}) + if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { + t.Fatalf("create user hooks directory: %v", err) + } + if err := os.Symlink(target, fixture.configPath); err != nil { + t.Skipf("create source symlink: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted a symlink hook source") + } + assertCopilotRuntimeAbsent(t, fixture) + }) +} + +func TestCopilotInstallRequiresNodeBeforeWrites(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + t.Setenv("PATH", "") + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "Node.js") { + t.Fatalf("install error = %v", err) + } + assertCopilotRuntimeAbsent(t, fixture) +} + +func TestCopilotStatusReportsMalformedRuntimeConfig(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + installCopilotFixture(t, fixture) + if err := os.WriteFile(fixture.runtimeConfig, []byte("{"), 0600); err != nil { + t.Fatalf("write malformed runtime config: %v", err) + } + + status, err := fixture.plugin.Status() + if err == nil || status.Installed { + t.Fatalf("Copilot status = %#v, %v", status, err) + } +} + +func TestCopilotStatusRejectsSymlinkRuntimeFiles(t *testing.T) { + for _, name := range []string{"config", "guard", "audit"} { + t.Run(name, func(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + installCopilotFixture(t, fixture) + path := map[string]string{ + "config": fixture.runtimeConfig, + "guard": fixture.guardPath, + "audit": fixture.hookPath, + }[name] + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s runtime: %v", name, err) + } + target := filepath.Join(fixture.homeDir, name+"-runtime-target") + if err := os.WriteFile(target, content, 0600); err != nil { + t.Fatalf("write %s runtime target: %v", name, err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("remove %s runtime: %v", name, err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("create %s runtime symlink: %v", name, err) + } + + status, err := fixture.plugin.Status() + if err == nil || status.Installed { + t.Fatalf("Copilot status = %#v, %v", status, err) + } + }) + } +} + +func TestCopilotInstallRollsBackEveryFileOnCommitFailure(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + userRaw: copilotJSON(map[string]any{ + "version": float64(1), + "hooks": map[string]any{ + "notification": []any{map[string]any{"type": "command", "command": "keep-user"}}, + }, + }), + }) + writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, map[string]any{ + "notification": []any{map[string]any{"type": "command", "command": "keep-project"}}, + })) + userBefore, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read user source: %v", err) + } + projectBefore, err := os.ReadFile(fixture.legacyPath) + if err != nil { + t.Fatalf("read project source: %v", err) + } + commits := 0 + failed := false + fixture.plugin.rename = func(source, destination string) error { + if !failed && strings.HasSuffix(source, ".tmp") { + commits++ + if commits == 5 { + failed = true + return errors.New("injected Copilot commit failure") + } + } + return os.Rename(source, destination) + } + + err = fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected Copilot commit failure") { + t.Fatalf("install error = %v", err) + } + assertCopilotFileBytes(t, fixture.configPath, userBefore) + assertCopilotFileBytes(t, fixture.legacyPath, projectBefore) + assertCopilotRuntimeAbsent(t, fixture) + assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir) +} + +func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + userRaw: copilotJSON(map[string]any{ + "version": float64(1), + "hooks": map[string]any{}, + }), + }) + concurrent := []byte(`{"version":1,"hooks":{"notification":[{"type":"command","command":"concurrent"}]}}` + "\n") + mutated := false + fixture.plugin.rename = func(source, destination string) error { + if !mutated && strings.HasSuffix(source, ".tmp") { + mutated = true + if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { + return err + } + } + return os.Rename(source, destination) + } + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("install error = %v", err) + } + assertCopilotFileBytes(t, fixture.configPath, concurrent) + assertCopilotRuntimeAbsent(t, fixture) + assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir) +} + +func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) { + t.Helper() + for _, path := range []string{fixture.runtimeConfig, fixture.privateKey, fixture.hookPath} { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime file exists at %s: %v", path, err) + } + } +} + +func assertCopilotFileEquals(t *testing.T, path, expected string) { + t.Helper() + assertCopilotFileBytes(t, path, []byte(expected)) +} + +func assertCopilotFileBytes(t *testing.T, path string, expected []byte) { + t.Helper() + actual, err := os.ReadFile(path) + if err != nil || string(actual) != string(expected) { + t.Fatalf("file %s = %q, %v; want %q", path, actual, err, expected) + } +} + +func assertNoCopilotTransactionArtifacts(t *testing.T, roots ...string) { + t.Helper() + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk transaction root %s: %v", root, err) + } + } +} diff --git a/cmd/elydora/plugins/copilot_test.go b/cmd/elydora/plugins/copilot_test.go new file mode 100644 index 0000000..8712d1c --- /dev/null +++ b/cmd/elydora/plugins/copilot_test.go @@ -0,0 +1,258 @@ +package plugins + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestCopilotRegistryUsesNativeUserHooks(t *testing.T) { + want := AgentRegistryEntry{ + Name: "GitHub Copilot CLI", + ConfigDir: "~/.copilot/hooks", + ConfigFile: "elydora-audit.json", + } + if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) { + t.Fatalf("Copilot registry = %#v, want %#v", got, want) + } +} + +func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + userRaw: copilotJSON(map[string]any{ + "version": float64(1), + "disableAllHooks": false, + "hooks": map[string]any{ + "sessionStart": []any{map[string]any{"type": "command", "command": "user-session"}}, + "preToolUse": []any{map[string]any{"type": "command", "command": "user-pre"}}, + }, + }), + }) + writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, map[string]any{ + "notification": []any{map[string]any{"type": "command", "command": "user-notification"}}, + })) + + installCopilotFixture(t, fixture) + installCopilotFixture(t, fixture) + + settings := readCopilotObject(t, fixture.configPath) + if settings["disableAllHooks"] != false { + t.Fatalf("disableAllHooks = %#v", settings["disableAllHooks"]) + } + hooks := requireCopilotObject(t, settings["hooks"]) + if got := requireCopilotArray(t, hooks["sessionStart"]); len(got) != 1 { + t.Fatalf("sessionStart hooks = %#v", got) + } + preHooks := requireCopilotArray(t, hooks["preToolUse"]) + postHooks := requireCopilotArray(t, hooks["postToolUse"]) + if len(preHooks) != 2 || len(postHooks) != 1 { + t.Fatalf("Copilot hooks = %#v", hooks) + } + if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" { + t.Fatalf("first preToolUse hook = %#v", preHooks[0]) + } + assertNativeCopilotHandler(t, managedCopilotTestHandler( + t, settings, "preToolUse", copilotGuardScript, + )) + assertNativeCopilotHandler(t, managedCopilotTestHandler( + t, settings, "postToolUse", copilotAuditScript, + )) + legacy := readCopilotObject(t, fixture.legacyPath) + legacyHooks := requireCopilotObject(t, legacy["hooks"]) + if len(legacyHooks) != 1 || legacyHooks["notification"] == nil { + t.Fatalf("migrated project hooks = %#v", legacyHooks) + } + runtimeConfig := readCopilotObject(t, fixture.runtimeConfig) + if runtimeConfig["agent_name"] != copilotAgentKey || + runtimeConfig["agent_id"] != copilotTestAgentID { + t.Fatalf("runtime config = %#v", runtimeConfig) + } + privateKey, err := os.ReadFile(fixture.privateKey) + if err != nil || string(privateKey) != fixture.config.PrivateKey { + t.Fatalf("private key = %q, %v", privateKey, err) + } + if exists, err := regularFileExists(fixture.hookPath, "test audit runtime"); err != nil || !exists { + t.Fatalf("audit runtime exists = %v, %v", exists, err) + } +} + +func TestCopilotMigrationRemovesOwnedProjectFile(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil)) + + installCopilotFixture(t, fixture) + + if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) { + t.Fatalf("owned project hook file remains: %v", err) + } + settings := readCopilotObject(t, fixture.configPath) + assertNativeCopilotHandler(t, managedCopilotTestHandler( + t, settings, "preToolUse", copilotGuardScript, + )) +} + +func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + installCopilotFixture(t, fixture) + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + captureScript := "const fs = require('node:fs');\n" + + "fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n" + if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { + t.Fatalf("write capture audit runtime: %v", err) + } + settings := readCopilotObject(t, fixture.configPath) + prePayload := `{"sessionId":"session-1","timestamp":1,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"}}` + "\n" + guard := managedCopilotTestHandler(t, settings, "preToolUse", copilotGuardScript) + guardResult := runCopilotHandler(t, guard, prePayload) + if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { + t.Fatalf("guard result = %#v", guardResult) + } + postPayload := `{"sessionId":"session-1","timestamp":2,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"toolResult":{"output":"ok"}}` + "\n" + audit := managedCopilotTestHandler(t, settings, "postToolUse", copilotAuditScript) + auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath) + if auditResult.exitCode != 0 { + t.Fatalf("audit result = %#v", auditResult) + } + captured, err := os.ReadFile(capturePath) + if err != nil || string(captured) != postPayload { + t.Fatalf("captured payload = %q, %v", captured, err) + } +} + +func TestCopilotEmptyHomeOverrideUsesOfficialDefault(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{emptyOverride: true}) + installCopilotFixture(t, fixture) + + settings := readCopilotObject(t, fixture.configPath) + assertNativeCopilotHandler(t, managedCopilotTestHandler( + t, settings, "preToolUse", copilotGuardScript, + )) + status, err := fixture.plugin.Status() + if err != nil || status.ConfigPath != fixture.configPath { + t.Fatalf("Copilot status = %#v, %v", status, err) + } +} + +func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + installCopilotFixture(t, fixture) + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { + t.Fatalf("installed status = %#v, %v", status, err) + } + + settings := readCopilotObject(t, fixture.configPath) + hooks := requireCopilotObject(t, settings["hooks"]) + delete(hooks, "postToolUse") + writeCopilotObject(t, fixture.configPath, settings) + status, err = fixture.plugin.Status() + if err != nil || status.HookConfigured || status.Installed { + t.Fatalf("incomplete status = %#v, %v", status, err) + } + + installCopilotFixture(t, fixture) + settings = readCopilotObject(t, fixture.configPath) + settings["disableAllHooks"] = true + writeCopilotObject(t, fixture.configPath, settings) + status, err = fixture.plugin.Status() + if err != nil || status.HookConfigured || status.Installed { + t.Fatalf("disabled status = %#v, %v", status, err) + } + + settings["disableAllHooks"] = false + writeCopilotObject(t, fixture.configPath, settings) + if err := os.Remove(fixture.hookPath); err != nil { + t.Fatalf("remove audit runtime: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || !status.HookConfigured || status.HookScriptExists || status.Installed { + t.Fatalf("missing runtime status = %#v, %v", status, err) + } + + installCopilotFixture(t, fixture) + runtimeConfig := readCopilotObject(t, fixture.runtimeConfig) + runtimeConfig["agent_id"] = "another-agent" + writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig) + status, err = fixture.plugin.Status() + if err != nil || status.HookScriptExists || status.Installed { + t.Fatalf("identity mismatch status = %#v, %v", status, err) + } +} + +func TestCopilotStatusRecognizesActiveProjectDelivery(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil)) + writeCopilotObject(t, fixture.runtimeConfig, map[string]any{ + "agent_id": copilotTestAgentID, "agent_name": copilotAgentKey, + }) + if err := os.WriteFile(fixture.hookPath, []byte("process.stdin.resume();\n"), 0700); err != nil { + t.Fatalf("write audit runtime: %v", err) + } + + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || status.ConfigPath != fixture.legacyPath { + t.Fatalf("project delivery status = %#v, %v", status, err) + } +} + +func TestCopilotUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{ + userRaw: copilotJSON(map[string]any{ + "version": float64(1), + "hooks": map[string]any{ + "notification": []any{map[string]any{"type": "command", "command": "keep"}}, + }, + }), + }) + installCopilotFixture(t, fixture) + settings := readCopilotObject(t, fixture.configPath) + hooks := requireCopilotObject(t, settings["hooks"]) + preHooks := requireCopilotArray(t, hooks["preToolUse"]) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + preHooks = append(preHooks, + buildCopilotHandler( + nodePath, + filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", copilotGuardScript), + ), + map[string]any{ + "type": "command", "bash": "echo elydora", "powershell": "Write-Output elydora", + "timeoutSec": copilotHookTimeout, + }, + ) + hooks["preToolUse"] = preHooks + writeCopilotObject(t, fixture.configPath, settings) + uninstallID := copilotTestAgentID + if runtime.GOOS == "windows" { + uninstallID = strings.ToUpper(uninstallID) + } + if err := fixture.plugin.Uninstall(uninstallID); err != nil { + t.Fatalf("uninstall GitHub Copilot hooks: %v", err) + } + + remaining := readCopilotObject(t, fixture.configPath) + remainingHooks := requireCopilotObject(t, remaining["hooks"]) + if remainingHooks["postToolUse"] != nil || len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 2 { + t.Fatalf("remaining hooks = %#v", remainingHooks) + } + if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 { + t.Fatalf("notification hooks = %#v", remainingHooks["notification"]) + } +} + +func TestCopilotUninstallLeavesAbsentSourcesAbsent(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + if err := fixture.plugin.Uninstall(copilotTestAgentID); err != nil { + t.Fatalf("uninstall absent GitHub Copilot hooks: %v", err) + } + for _, path := range []string{fixture.configPath, fixture.legacyPath} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("absent source created at %s: %v", path, err) + } + } +} diff --git a/cmd/elydora/plugins/copilot_test_helpers_test.go b/cmd/elydora/plugins/copilot_test_helpers_test.go new file mode 100644 index 0000000..7690d5e --- /dev/null +++ b/cmd/elydora/plugins/copilot_test_helpers_test.go @@ -0,0 +1,269 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const copilotTestAgentID = "agent-1" + +type copilotFixtureOptions struct { + userRaw *string + legacyRaw *string + skipGuard bool + emptyOverride bool +} + +type copilotFixture struct { + plugin *CopilotPlugin + config InstallConfig + homeDir string + projectDir string + copilotHome string + agentDir string + configPath string + legacyPath string + guardPath string + hookPath string + runtimeConfig string + privateKey string +} + +type copilotCommandResult struct { + exitCode int + stdout string + stderr string +} + +func copilotString(value string) *string { + return &value +} + +func copilotJSON(value any) *string { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + panic(err) + } + return copilotString(string(encoded) + "\n") +} + +func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilotFixture { + t.Helper() + rootDir := t.TempDir() + homeDir := filepath.Join(rootDir, "home with spaces and 'quote") + projectDir := filepath.Join(rootDir, "project with spaces") + copilotHome := filepath.Join(homeDir, "custom copilot") + agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID) + configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile) + legacyPath := filepath.Join(projectDir, ".github", "hooks", "hooks.json") + guardPath := filepath.Join(agentDir, copilotGuardScript) + hookPath := filepath.Join(agentDir, copilotAuditScript) + for _, directory := range []string{projectDir, agentDir} { + if err := os.MkdirAll(directory, 0700); err != nil { + t.Fatalf("create fixture directory %s: %v", directory, err) + } + } + if !options.skipGuard { + guard := "const fs = require('node:fs');\n" + + "fs.readFileSync(0);\n" + + "process.stderr.write('Agent is frozen by Elydora.');\n" + + "process.exit(2);\n" + if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil { + t.Fatalf("write guard runtime: %v", err) + } + } + writeOptionalCopilotFile(t, configPath, options.userRaw) + writeOptionalCopilotFile(t, legacyPath, options.legacyRaw) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + if options.emptyOverride { + t.Setenv("COPILOT_HOME", " ") + configPath = filepath.Join(homeDir, ".copilot", "hooks", copilotConfigFile) + } else { + t.Setenv("COPILOT_HOME", copilotHome) + } + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("read current directory: %v", err) + } + if err := os.Chdir(projectDir); err != nil { + t.Fatalf("enter fixture project: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDirectory); err != nil { + t.Errorf("restore current directory: %v", err) + } + }) + return &copilotFixture{ + plugin: &CopilotPlugin{}, + homeDir: homeDir, + projectDir: projectDir, + copilotHome: copilotHome, + agentDir: agentDir, + configPath: configPath, + legacyPath: legacyPath, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + config: InstallConfig{ + AgentName: copilotAgentKey, OrgID: "org-1", AgentID: copilotTestAgentID, + PrivateKey: "test-key", KID: "kid-1", Token: "token-1", + BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, + }, + } +} + +func writeOptionalCopilotFile(t *testing.T, path string, source *string) { + t.Helper() + if source == nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create directory for %s: %v", path, err) + } + if err := os.WriteFile(path, []byte(*source), 0600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func writeCopilotObject(t *testing.T, path string, value map[string]any) { + t.Helper() + writeOptionalCopilotFile(t, path, copilotJSON(value)) +} + +func readCopilotObject(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +func requireCopilotObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("value is not an object: %#v", value) + } + return object +} + +func requireCopilotArray(t *testing.T, value any) []any { + t.Helper() + array, ok := value.([]any) + if !ok { + t.Fatalf("value is not an array: %#v", value) + } + return array +} + +func managedCopilotTestHandler( + t *testing.T, + settings map[string]any, + event string, + scriptName string, +) map[string]any { + t.Helper() + hooks := requireCopilotObject(t, settings["hooks"]) + for _, value := range requireCopilotArray(t, hooks[event]) { + handler := requireCopilotObject(t, value) + if strings.Contains(stringValue(handler["bash"]), scriptName) { + return handler + } + } + t.Fatalf("managed %s handler for %s was not found", event, scriptName) + return nil +} + +func stringValue(value any) string { + result, _ := value.(string) + return result +} + +func assertNativeCopilotHandler(t *testing.T, handler map[string]any) { + t.Helper() + if len(handler) != 4 || handler["type"] != "command" || + handler["timeoutSec"] != copilotHookTimeout { + t.Fatalf("Copilot handler = %#v", handler) + } + if !strings.Contains(stringValue(handler["bash"]), "node") { + t.Fatalf("Copilot bash handler = %#v", handler["bash"]) + } + powershell := stringValue(handler["powershell"]) + if !strings.HasPrefix(powershell, "& ") || !strings.HasSuffix(powershell, "; exit $LASTEXITCODE") { + t.Fatalf("Copilot PowerShell handler = %q", powershell) + } +} + +func legacyCopilotConfig(fixture *copilotFixture, extraHooks map[string]any) map[string]any { + hooks := map[string]any{ + "preToolUse": []any{map[string]any{ + "type": "command", "bash": "node " + fixture.guardPath, + "powershell": "node " + fixture.guardPath, "timeoutSec": copilotLegacyTimeout, + }}, + "postToolUse": []any{map[string]any{ + "type": "command", "bash": "node " + fixture.hookPath, + "powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout, + }}, + } + for key, value := range extraHooks { + hooks[key] = value + } + return map[string]any{"version": float64(1), "hooks": hooks} +} + +func runCopilotHandler( + t *testing.T, + handler map[string]any, + payload string, + environment ...string, +) copilotCommandResult { + t.Helper() + var process *exec.Cmd + if runtime.GOOS == "windows" { + process = exec.Command( + "powershell.exe", "-NoProfile", "-NonInteractive", "-Command", + stringValue(handler["powershell"]), + ) + } else { + process = exec.Command("sh", "-c", stringValue(handler["bash"])) + } + process.Env = append(os.Environ(), environment...) + process.Stdin = strings.NewReader(payload) + var stdout bytes.Buffer + var stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + err := process.Run() + if err == nil { + return copilotCommandResult{stdout: stdout.String(), stderr: stderr.String()} + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return copilotCommandResult{ + exitCode: exitError.ExitCode(), stdout: stdout.String(), stderr: stderr.String(), + } + } + t.Fatalf("run GitHub Copilot hook: %v", err) + return copilotCommandResult{exitCode: -1} +} + +func installCopilotFixture(t *testing.T, fixture *copilotFixture) { + t.Helper() + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install GitHub Copilot hooks: %v", err) + } +} diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index 6c6b2b9..b58f7b2 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -21,7 +21,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, "kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"}, "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, - "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"}, + "copilot": {Name: "GitHub Copilot CLI", ConfigDir: "~/.copilot/hooks", ConfigFile: "elydora-audit.json"}, "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"}, "qwen": {Name: "Qwen Code", ConfigDir: "~/.qwen", ConfigFile: "settings.json"}, }