fix(go): sync hardened Copilot hooks
This commit is contained in:
1 parent
5384565b4f
commit
b6112cffd9
9 files changed
+1567
-207
No files matched your search
@@ -29,6 +29,7 @@ This repository publishes the open-source server, console, integration catalog,
|
||||
- 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.
|
||||
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from the JavaScript validator so recovery remains available offline.
|
||||
- Write Cline file hooks to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Use supported event filenames, preserve official hook input byte-for-byte, use `process.execPath` in Node and Go wrappers, and resolve Python runtimes from absolute shebangs. Translate guard exit code `2` into JSON stdout with `cancel: true`. Treat hook errors, timeouts, and invalid control JSON as fail-open.
|
||||
- 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.
|
||||
- Model Cursor as one CLI-and-IDE hook contract. Preserve user, project, and enterprise sources in documented priority order; forward `tool_name`, `tool_input`, and `conversation_id`; use exit code `2` for denial; record `failClosed` as the per-script failure-mode control.
|
||||
- Preserve Factory Droid's scope-root `hooks.json` precedence, legacy nested hook source, and per-event `settings.json` fallback. Scope-root and legacy files store event keys at the document root; settings stores the same map under `hooks`. Edit only effective user sources; keep project and organization sources read-only. Preserve JSONC comments through syntax-tree edits, use SDK-native absolute runtime commands, commit generated runtime artifacts and hook sources as one recoverable transaction, and require `/hooks` review after external changes.
|
||||
- Qwen Code `0.20.0` resolves user settings through explicit `QWEN_HOME`, then user-level `.qwen/.env`, then `~/.env`, with `~/.qwen/settings.json` as the default. Keep workspace settings read-only. Preserve JSON comments, reject trailing commas and duplicate keys before writes, express timeouts in milliseconds, propagate native exit code `2` through PowerShell, and require `/hooks` review.
|
||||
|
||||
+3
-1
@@ -78,6 +78,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`.
|
||||
@@ -109,7 +111,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` |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
guardPath := config.GuardScriptPath
|
||||
if guardPath == "" {
|
||||
guardPath, err = guardScriptPath(config.AgentID)
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
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)
|
||||
}
|
||||
change, changeErr := prepareRenderedCopilotChange(rendered)
|
||||
if changeErr != nil {
|
||||
return changeErr
|
||||
}
|
||||
preFiltered = append(preFiltered, entry)
|
||||
changes = append(changes, change)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
status.HookScriptExists, err = copilotRuntimeFilesExist(contracts)
|
||||
if err != nil {
|
||||
return status, err
|
||||
}
|
||||
status.Installed = status.HookScriptExists
|
||||
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
|
||||
func requireCopilotRuntime(path, label string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("%s path is required", label)
|
||||
}
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%s is missing: %s", label, path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s at %s: %w", label, path, err)
|
||||
}
|
||||
|
||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
||||
return status, nil
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("%s path is not a physical file: %s", label, path)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
if ps, _ := m["powershell"].(string); strings.Contains(ps, "elydora") {
|
||||
return true
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ""
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user