fix: harden GitHub Copilot hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 23:13:24 -04:00
1 parent 996672a6a7
commit bf777a2ff0
9 files changed
+1564 -204

No files matched your search

+1
View File
@@ -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. - 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 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 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. - 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. - 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. - 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.
+3 -1
View File
@@ -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. 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. 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`. 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` | | Claude Code | `claudecode` |
| OpenAI Codex | `codex` | | OpenAI Codex | `codex` |
| Cline | `cline` | | Cline | `cline` |
| Copilot CLI | `copilot` | | GitHub Copilot CLI | `copilot` |
| Cursor | `cursor` | | Cursor | `cursor` |
| Factory Droid | `droid` | | Factory Droid | `droid` |
| Gemini CLI | `gemini` | | Gemini CLI | `gemini` |
+106 -202
View File
@@ -4,258 +4,162 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
// CopilotPlugin manages the Elydora audit hook for GitHub Copilot CLI. // CopilotPlugin manages GitHub Copilot CLI native user hooks.
// It writes/merges into .github/hooks/hooks.json (project-relative) using type CopilotPlugin struct {
// hooks.preToolUse[]/postToolUse[] with bash/powershell command fields. rename renameFunc
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
} }
func (p *CopilotPlugin) Install(config InstallConfig) error { 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 { if err != nil {
return err return err
} }
if config.HookScript != "" { if sources.user.disabled {
scriptPath = config.HookScript return fmt.Errorf(
"GitHub Copilot user hooks are disabled by disableAllHooks at %s",
sources.user.filePath,
)
} }
runtimeRoot, err := AgentRuntimeRoot()
if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err)
}
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
if err != nil { if err != nil {
return err return err
} }
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return err
} }
expectedGuard := filepath.Join(agentDirectory, copilotGuardScript)
configPath, err := p.configPath() 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 { if err != nil {
return err 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 { if err != nil {
return err return err
} }
if err := writeChanges(changes, "Install GitHub Copilot hooks", p.rename); err != nil {
// 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 {
return err 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 return nil
} }
func (p *CopilotPlugin) Uninstall(agentID string) error { func (p *CopilotPlugin) Uninstall(agentID string) error {
configPath, err := p.configPath() sources, err := readCopilotSources()
if err != nil { if err != nil {
return err return err
} }
runtimeRoot, err := AgentRuntimeRoot()
settings, err := readJSONFile(configPath)
if err != nil { if err != nil {
return err return err
} }
documents := []*copilotDocument{sources.user}
hooks, _ := settings["hooks"].(map[string]interface{}) if sources.legacy != nil {
if hooks == nil { documents = append(documents, sources.legacy)
fmt.Println("No Copilot CLI hooks found.")
return nil
} }
changes := make([]*fileChange, 0, len(documents))
// Remove preToolUse Elydora entries for _, document := range documents {
preToolUse, _ := hooks["preToolUse"].([]interface{}) hooks := removeManagedCopilotHooks(document.hooks, runtimeRoot, agentID)
var preFiltered []interface{} rendered, renderErr := renderCopilotDocument(document, hooks)
for _, entry := range preToolUse { if renderErr != nil {
if m, ok := entry.(map[string]interface{}); ok { return fmt.Errorf("render GitHub Copilot hook source: %w", renderErr)
if isCopilotElydoraEntry(m) {
continue
} }
change, changeErr := prepareRenderedCopilotChange(rendered)
if changeErr != nil {
return changeErr
} }
preFiltered = append(preFiltered, entry) changes = append(changes, change)
} }
if len(preFiltered) == 0 { return writeChanges(changes, "Uninstall GitHub Copilot hooks", p.rename)
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
}
}
postFiltered = append(postFiltered, entry)
}
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
} }
func (p *CopilotPlugin) Status() (PluginStatus, error) { func (p *CopilotPlugin) Status() (PluginStatus, error) {
configPath, err := p.configPath() userPath, _, pathErr := copilotConfigPaths()
if err != nil { entry := SupportedAgents[copilotAgentKey]
return PluginStatus{}, err
}
status := PluginStatus{ status := PluginStatus{
AgentName: "copilot", AgentName: copilotAgentKey, DisplayName: entry.Name, ConfigPath: userPath,
DisplayName: "Copilot CLI",
ConfigPath: configPath,
} }
if pathErr != nil {
settings, err := readJSONFile(configPath) return status, pathErr
}
sources, err := readCopilotSources()
if err != nil { 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 return status, nil
} }
status.HookScriptExists, err = copilotRuntimeFilesExist(contracts)
hooks, _ := settings["hooks"].(map[string]interface{}) if err != nil {
if hooks != nil { return status, err
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.Installed = status.HookScriptExists
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil return status, nil
} }
// isCopilotElydoraEntry checks if a Copilot hook entry (bash/powershell fields) is an Elydora hook. func requireCopilotRuntime(path, label string) error {
func isCopilotElydoraEntry(m map[string]interface{}) bool { if path == "" {
if bash, _ := m["bash"].(string); strings.Contains(bash, "elydora") { return fmt.Errorf("%s path is required", label)
return true
} }
if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") { info, err := os.Lstat(path)
return true if os.IsNotExist(err) {
return fmt.Errorf("%s is missing: %s", label, path)
} }
return false if err != nil {
} return fmt.Errorf("read %s at %s: %w", label, path, err)
// 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 info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%s path is not a physical file: %s", label, path)
} }
} return nil
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)
}
}
}
return ""
} }
+330
View File
@@ -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
}
+309
View File
@@ -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
}
@@ -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)
}
}
}
+258
View File
@@ -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)
}
}
}
@@ -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)
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"}, "kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "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"}, "letta": {Name: "Letta Code", ConfigDir: "~/.letta", ConfigFile: "settings.json"},
"qwen": {Name: "Qwen Code", ConfigDir: "~/.qwen", ConfigFile: "settings.json"}, "qwen": {Name: "Qwen Code", ConfigDir: "~/.qwen", ConfigFile: "settings.json"},
} }