fix(go): mirror official Copilot hook contract

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 13:03:27 -04:00
1 parent 9b67aad113
commit 42a7653a77
15 files changed
+2194 -462

No files matched your search

+1 -1
View File
@@ -84,7 +84,7 @@ Cursor installation writes native global `preToolUse`, `postToolUse`, and `postT
Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` command handlers to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Elydora preserves JSONC comments, line endings, unrelated user hooks, and additive workspace, system, system-override, and extension sources. The user settings document and all four generated runtime artifacts commit in one rollback-capable transaction. Generated hooks retain Gemini's native snake_case payload, emit `{}` on successful or fail-open execution, surface runtime failures through stderr and `error.log`, and use exit code `2` for frozen or revoked agents. Installation honors `hooksConfig` controls and uses encoded PowerShell commands on Windows. Run `/hooks list` after installation to inspect the effective hook inventory. Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` command handlers to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Elydora preserves JSONC comments, line endings, unrelated user hooks, and additive workspace, system, system-override, and extension sources. The user settings document and all four generated runtime artifacts commit in one rollback-capable transaction. Generated hooks retain Gemini's native snake_case payload, emit `{}` on successful or fail-open execution, surface runtime failures through stderr and `error.log`, and use exit code `2` for frozen or revoked agents. Installation honors `hooksConfig` controls and uses encoded PowerShell commands on Windows. Run `/hooks list` after installation to inspect the effective hook inventory.
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. GitHub Copilot CLI installation writes native `preToolUse`, `postToolUse`, and `postToolUseFailure` user hooks to `$COPILOT_HOME/hooks/elydora-audit.json`, with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves additive hook sources, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, validates the official handler schema and JavaScript matcher syntax, resolves `disableAllHooks` through the CLI's settings precedence, and commits all runtime and hook files transactionally. The generated commands preserve native camelCase payloads and the guard propagates exit code `2`; restart active Copilot sessions after installation.
Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key. Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hook.js`, `PreToolUse.mjs`, and `PostToolUse.mjs` through one rollback-capable transaction. The native hooks live in `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; the Documents and workspace roots remain read-only. The wrappers forward Cline's complete stdin payload byte-for-byte, use the active Node.js executable for generated runtimes, and emit pure JSON cancellation control for frozen or revoked agents. Status requires physical files, exact generated sources, strict runtime identity, and a canonical private key.
+168 -103
View File
@@ -2,7 +2,6 @@ package plugins
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
) )
@@ -11,131 +10,214 @@ type CopilotPlugin struct {
rename renameFunc rename renameFunc
} }
func (p *CopilotPlugin) Install(config InstallConfig) error { // ManagesGuardRuntime reports that Copilot commits its provider guard with the
if config.AgentID == "" { // audit runtime and hook documents.
return fmt.Errorf("agent ID is required") func (p *CopilotPlugin) ManagesGuardRuntime() bool {
} return true
sources, err := readCopilotSources() }
if err != nil {
return err
}
if sources.user.disabled {
return fmt.Errorf(
"GitHub Copilot user hooks are disabled by disableAllHooks at %s",
sources.user.filePath,
)
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return err
}
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
}
userHooks := removeManagedCopilotHooks(sources.user.hooks, runtimeRoot, "") // PreflightInstall validates hook sources, settings, matchers, credentials,
userHooks["preToolUse"] = append( // and runtime identity before any write.
userHooks["preToolUse"], func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error {
buildCopilotHandler(nodePath, expectedGuard), sources, _, err := readCopilotSources()
)
userHooks["postToolUse"] = append(
userHooks["postToolUse"],
buildCopilotHandler(nodePath, auditPath),
)
userRendered, err := renderCopilotDocument(sources.user, userHooks)
if err != nil { if err != nil {
return fmt.Errorf("render GitHub Copilot user hooks: %w", err) return err
} }
rendered := []*copilotRenderedDocument{userRendered} _, _, err = preflightCopilotInstallation(config, sources)
return err
}
func renderCopilotInstallation(
sources *copilotSources,
guardPath string,
auditPath string,
nodePath string,
) ([]*copilotRenderedDocument, error) {
hooks, err := removeManagedCopilotHooks(sources.user.hooks, "")
if err != nil {
return nil, err
}
for _, item := range []struct {
event string
scriptPath string
}{
{"preToolUse", guardPath},
{"postToolUse", auditPath},
{"postToolUseFailure", auditPath},
} {
hooks[item.event] = append(
hooks[item.event],
buildCopilotHandler(nodePath, item.scriptPath),
)
}
user, err := renderCopilotDocument(sources.user, hooks)
if err != nil {
return nil, fmt.Errorf("render GitHub Copilot user hooks: %w", err)
}
rendered := []*copilotRenderedDocument{user}
if sources.legacy != nil { if sources.legacy != nil {
legacyHooks := removeManagedCopilotHooks(sources.legacy.hooks, runtimeRoot, "") legacyHooks, removeErr := removeManagedCopilotHooks(sources.legacy.hooks, "")
legacyRendered, renderErr := renderCopilotDocument(sources.legacy, legacyHooks) if removeErr != nil {
return nil, removeErr
}
legacy, renderErr := renderCopilotDocument(sources.legacy, legacyHooks)
if renderErr != nil { if renderErr != nil {
return fmt.Errorf("render GitHub Copilot project hooks: %w", renderErr) return nil, fmt.Errorf("render GitHub Copilot legacy project hooks: %w", renderErr)
} }
rendered = append(rendered, legacyRendered) rendered = append(rendered, legacy)
} }
changes, err := prepareCopilotInstallationChanges( return rendered, nil
config, }
agentDirectory,
auditPath, func (p *CopilotPlugin) Install(config InstallConfig) error {
rendered, sources, _, err := readCopilotSources()
if err != nil {
return err
}
paths, nodePath, err := preflightCopilotInstallation(config, sources)
if err != nil {
return err
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
) )
if err != nil { if err != nil {
return err return err
} }
if err := writeChanges(changes, "Install GitHub Copilot hooks", p.rename); err != nil { prepared, err := prepareCopilotInstallation(config, sources, rendered)
if err != nil {
return err return err
} }
fmt.Printf(" GitHub Copilot CLI hooks: %s\n", sources.user.filePath) if err := commitCopilotInstallation(prepared, p.rename); err != nil {
return err
}
fmt.Printf("GitHub Copilot CLI hooks: %s\n", sources.user.filePath)
fmt.Println("GitHub Copilot CLI: restart active sessions to load updated hooks.")
return nil return nil
} }
func (p *CopilotPlugin) Uninstall(agentID string) error { func renderCopilotUninstall(
sources, err := readCopilotSources() sources *copilotSources,
if err != nil { agentID string,
return err ) ([]*copilotRenderedDocument, error) {
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return err
}
documents := []*copilotDocument{sources.user} documents := []*copilotDocument{sources.user}
if sources.legacy != nil { if sources.legacy != nil {
documents = append(documents, sources.legacy) documents = append(documents, sources.legacy)
} }
changes := make([]*fileChange, 0, len(documents)) rendered := make([]*copilotRenderedDocument, 0, len(documents))
for _, document := range documents { for _, document := range documents {
hooks := removeManagedCopilotHooks(document.hooks, runtimeRoot, agentID) hooks, err := removeManagedCopilotHooks(document.hooks, agentID)
rendered, renderErr := renderCopilotDocument(document, hooks) if err != nil {
if renderErr != nil { return nil, err
return fmt.Errorf("render GitHub Copilot hook source: %w", renderErr)
} }
change, changeErr := prepareRenderedCopilotChange(rendered) item, err := renderCopilotDocument(document, hooks)
if err != nil {
return nil, fmt.Errorf("render GitHub Copilot hook source: %w", err)
}
rendered = append(rendered, item)
}
return rendered, nil
}
func (p *CopilotPlugin) Uninstall(agentID string) error {
sources, _, err := readCopilotSources()
if err != nil {
return err
}
rendered, err := renderCopilotUninstall(sources, agentID)
if err != nil {
return err
}
changes := make([]*fileChange, 0, len(rendered))
for _, document := range rendered {
change, changeErr := prepareRenderedCopilotChange(document)
if changeErr != nil { if changeErr != nil {
return changeErr return changeErr
} }
changes = append(changes, change) changes = append(changes, change)
} }
return writeChanges(changes, "Uninstall GitHub Copilot hooks", p.rename) return writeCopilotChanges(
changes,
"Uninstall GitHub Copilot hooks",
p.rename,
"",
"",
nil,
)
}
func mergedCopilotContracts(
sources *copilotSources,
) ([]copilotRuntimeContract, error) {
contracts, err := copilotRuntimeContracts(sources.user.hooks)
if err != nil {
return nil, err
}
if sources.legacy != nil {
legacy, legacyErr := copilotRuntimeContracts(sources.legacy.hooks)
if legacyErr != nil {
return nil, legacyErr
}
contracts = append(contracts, legacy...)
}
unique := map[string]copilotRuntimeContract{}
for _, contract := range contracts {
unique[copilotEntryKey(contract.agentID)] = contract
}
result := make([]copilotRuntimeContract, 0, len(unique))
for _, contract := range unique {
result = append(result, contract)
}
return result, nil
}
func configuredCopilotPath(
sources *copilotSources,
defaultPath string,
) (string, error) {
userContracts, err := copilotRuntimeContracts(sources.user.hooks)
if err != nil {
return "", err
}
if len(userContracts) > 0 {
return sources.user.filePath, nil
}
if sources.legacy != nil {
legacyContracts, legacyErr := copilotRuntimeContracts(sources.legacy.hooks)
if legacyErr != nil {
return "", legacyErr
}
if len(legacyContracts) > 0 {
return sources.legacy.filePath, nil
}
}
return filepath.Clean(defaultPath), nil
} }
func (p *CopilotPlugin) Status() (PluginStatus, error) { func (p *CopilotPlugin) Status() (PluginStatus, error) {
userPath, _, pathErr := copilotConfigPaths() paths, pathErr := resolveCopilotPaths()
entry := SupportedAgents[copilotAgentKey] entry := SupportedAgents[copilotAgentKey]
status := PluginStatus{ status := PluginStatus{AgentName: copilotAgentKey, DisplayName: entry.Name}
AgentName: copilotAgentKey, DisplayName: entry.Name, ConfigPath: userPath,
}
if pathErr != nil { if pathErr != nil {
return status, pathErr return status, pathErr
} }
sources, err := readCopilotSources() status.ConfigPath = paths.userHookPath
sources, _, err := readCopilotSources()
if err != nil { if err != nil {
return status, err return status, err
} }
runtimeRoot, err := AgentRuntimeRoot() contracts, err := mergedCopilotContracts(sources)
if err != nil { if err != nil {
return status, err return status, err
} }
contracts := activeCopilotContracts(sources, runtimeRoot) status.ConfigPath, err = configuredCopilotPath(sources, paths.userHookPath)
status.ConfigPath = configuredCopilotPath(sources, runtimeRoot) if err != nil {
status.HookConfigured = len(contracts) > 0 return status, err
}
status.HookConfigured = sources.disabledBy == "" && len(contracts) > 0
if !status.HookConfigured { if !status.HookConfigured {
return status, nil return status, nil
} }
@@ -146,20 +228,3 @@ func (p *CopilotPlugin) Status() (PluginStatus, error) {
status.Installed = status.HookScriptExists status.Installed = status.HookScriptExists
return status, nil return status, nil
} }
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)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%s path is not a physical file: %s", label, path)
}
return nil
}
+190 -70
View File
@@ -1,7 +1,9 @@
package plugins package plugins
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort" "sort"
@@ -18,6 +20,15 @@ const (
copilotPOSIXApostrophe = `'"'"'` copilotPOSIXApostrophe = `'"'"'`
) )
var copilotManagedEvents = []struct {
event string
script string
}{
{"preToolUse", copilotGuardScript},
{"postToolUse", copilotAuditScript},
{"postToolUseFailure", copilotAuditScript},
}
type copilotHooks map[string][]map[string]any type copilotHooks map[string][]map[string]any
type copilotDocument struct { type copilotDocument struct {
@@ -26,12 +37,21 @@ type copilotDocument struct {
root map[string]any root map[string]any
hooks copilotHooks hooks copilotHooks
raw []byte raw []byte
disabled bool snapshot *managedFileSnapshot
hooksDisabled bool
}
type copilotSourcePrecondition struct {
filePath string
label string
snapshot *managedFileSnapshot
} }
type copilotSources struct { type copilotSources struct {
user *copilotDocument user *copilotDocument
legacy *copilotDocument legacy *copilotDocument
disabledBy string
settingsPreconditions []copilotSourcePrecondition
} }
type copilotRenderedDocument struct { type copilotRenderedDocument struct {
@@ -47,10 +67,25 @@ type copilotRuntimeContract struct {
auditPath string auditPath string
} }
type copilotManagedEntry struct {
agentID string
scriptPath string
}
func cloneCopilotObject(value map[string]any) map[string]any {
clone := make(map[string]any, len(value))
for key, item := range value {
clone[key] = item
}
return clone
}
func cloneCopilotHooks(source copilotHooks) copilotHooks { func cloneCopilotHooks(source copilotHooks) copilotHooks {
clone := make(copilotHooks, len(source)) clone := make(copilotHooks, len(source))
for event, handlers := range source { for event, handlers := range source {
clone[event] = append([]map[string]any(nil), handlers...) copied := make([]map[string]any, len(handlers))
copy(copied, handlers)
clone[event] = copied
} }
return clone return clone
} }
@@ -77,6 +112,13 @@ func sameCopilotAgentID(left, right string) bool {
return left == right return left == right
} }
func sameCopilotFileName(left, right string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(left, right)
}
return left == right
}
func quoteCopilotPowerShell(value string) string { func quoteCopilotPowerShell(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'" return "'" + strings.ReplaceAll(value, "'", "''") + "'"
} }
@@ -198,48 +240,113 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) {
return parseCopilotLegacyCommand(bash) return parseCopilotLegacyCommand(bash)
} }
func copilotManagedAgentID( func copilotManagedEntryForHandler(
handler map[string]any, handler map[string]any,
scriptName string, scriptName string,
runtimeRoot string, ) (*copilotManagedEntry, error) {
) (string, bool) {
scriptPath, managed := copilotManagedScriptPath(handler) scriptPath, managed := copilotManagedScriptPath(handler)
if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName { if !managed || !filepath.IsAbs(scriptPath) ||
return "", false !sameCopilotFileName(filepath.Base(scriptPath), scriptName) {
return nil, nil
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
} }
agentDirectory := filepath.Dir(scriptPath) agentDirectory := filepath.Dir(scriptPath)
if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) { if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) {
return "", false return nil, nil
} }
agentID := filepath.Base(agentDirectory) agentID := filepath.Base(agentDirectory)
return agentID, agentID != "" && agentID != "." && agentID != ".." if agentID == "" || agentID == "." || agentID == ".." {
return nil, nil
}
return &copilotManagedEntry{agentID: agentID, scriptPath: scriptPath}, nil
} }
func removeManagedCopilotHooks( func removeManagedCopilotHooks(
hooks copilotHooks, hooks copilotHooks,
runtimeRoot string,
agentID string, agentID string,
) copilotHooks { ) (copilotHooks, error) {
result := cloneCopilotHooks(hooks) result := cloneCopilotHooks(hooks)
for _, contract := range []struct{ event, script string }{ for _, contract := range copilotManagedEvents {
{"preToolUse", copilotGuardScript},
{"postToolUse", copilotAuditScript},
} {
kept := make([]map[string]any, 0, len(result[contract.event])) kept := make([]map[string]any, 0, len(result[contract.event]))
for _, handler := range result[contract.event] { for _, handler := range result[contract.event] {
managedID, managed := copilotManagedAgentID(handler, contract.script, runtimeRoot) entry, err := copilotManagedEntryForHandler(handler, contract.script)
if managed && (agentID == "" || sameCopilotAgentID(managedID, agentID)) { if err != nil {
continue return nil, err
} }
remove := entry != nil &&
(agentID == "" || sameCopilotAgentID(entry.agentID, agentID))
if !remove {
kept = append(kept, handler) kept = append(kept, handler)
} }
}
if len(kept) == 0 { if len(kept) == 0 {
delete(result, contract.event) delete(result, contract.event)
} else { } else {
result[contract.event] = kept result[contract.event] = kept
} }
} }
return result return result, nil
}
func parseCopilotDocument(
filePath string,
snapshot *managedFileSnapshot,
label string,
) (*copilotDocument, error) {
if snapshot == nil {
return nil, fmt.Errorf("%s snapshot is required: %s", label, filePath)
}
documentLabel := fmt.Sprintf("%s at %s", label, filePath)
root, err := decodeStrictJSONObject(snapshot.contents, documentLabel)
if err != nil {
return nil, err
}
if root["version"] != float64(1) {
return nil, fmt.Errorf("%s must declare version 1", documentLabel)
}
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`, documentLabel)
}
}
hooks := copilotHooks{}
if value, exists := root["hooks"]; exists {
hooks, err = validateCopilotHooks(value, documentLabel)
if err != nil {
return nil, err
}
}
return &copilotDocument{
exists: true, filePath: filePath, root: root, hooks: hooks,
raw: append([]byte(nil), snapshot.contents...), snapshot: snapshot,
hooksDisabled: disabled,
}, nil
}
func createCopilotDocument(filePath string) *copilotDocument {
return &copilotDocument{
filePath: filePath,
root: map[string]any{},
hooks: copilotHooks{},
}
}
func emptyOwnedCopilotDocument(root map[string]any, hooks copilotHooks) bool {
if len(hooks) != 0 {
return false
}
for key := range root {
if key != "version" && key != "hooks" {
return false
}
}
return true
} }
func renderCopilotDocument( func renderCopilotDocument(
@@ -249,22 +356,12 @@ func renderCopilotDocument(
if !document.exists && len(hooks) == 0 { if !document.exists && len(hooks) == 0 {
return &copilotRenderedDocument{document: document}, nil return &copilotRenderedDocument{document: document}, nil
} }
if document.exists && len(hooks) == 0 { if document.exists && emptyOwnedCopilotDocument(document.root, hooks) {
owned := true return &copilotRenderedDocument{
for key := range document.root { document: document, changed: true, remove: true,
if key != "version" && key != "hooks" { }, nil
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 := cloneCopilotObject(document.root)
root["version"] = float64(1) root["version"] = float64(1)
if len(hooks) == 0 { if len(hooks) == 0 {
delete(root, "hooks") delete(root, "hooks")
@@ -273,58 +370,81 @@ func renderCopilotDocument(
} }
next, err := json.MarshalIndent(root, "", " ") next, err := json.MarshalIndent(root, "", " ")
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("encode GitHub Copilot hooks: %w", err)
} }
next = append(next, '\n') next = append(next, '\n')
if _, err := parseCopilotDocument(
document.filePath,
&managedFileSnapshot{contents: next},
"GitHub Copilot rendered hooks",
); err != nil {
return nil, fmt.Errorf("validate rendered GitHub Copilot hooks: %w", err)
}
return &copilotRenderedDocument{ return &copilotRenderedDocument{
document: document, document: document,
changed: !document.exists || string(next) != string(document.raw), changed: !document.exists || !bytes.Equal(next, document.raw),
next: next, next: next,
}, nil }, nil
} }
func copilotRuntimeContracts( func copilotEntryKey(agentID string) string {
hooks copilotHooks, if runtime.GOOS == "windows" {
runtimeRoot string, return strings.ToLower(agentID)
) []copilotRuntimeContract { }
guards := managedCopilotIDs(hooks["preToolUse"], copilotGuardScript, runtimeRoot) return agentID
audits := managedCopilotIDs(hooks["postToolUse"], copilotAuditScript, runtimeRoot) }
func copilotManagedEntries(
handlers []map[string]any,
scriptName string,
) (map[string][]copilotManagedEntry, error) {
result := map[string][]copilotManagedEntry{}
for _, handler := range handlers {
entry, err := copilotManagedEntryForHandler(handler, scriptName)
if err != nil {
return nil, err
}
if entry != nil {
key := copilotEntryKey(entry.agentID)
result[key] = append(result[key], *entry)
}
}
return result, nil
}
func copilotRuntimeContracts(hooks copilotHooks) ([]copilotRuntimeContract, error) {
guards, err := copilotManagedEntries(hooks["preToolUse"], copilotGuardScript)
if err != nil {
return nil, err
}
successes, err := copilotManagedEntries(hooks["postToolUse"], copilotAuditScript)
if err != nil {
return nil, err
}
failures, err := copilotManagedEntries(
hooks["postToolUseFailure"], copilotAuditScript,
)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(guards)) keys := make([]string, 0, len(guards))
for key := range guards { for key := range guards {
if _, exists := audits[key]; exists {
keys = append(keys, key) keys = append(keys, key)
} }
}
sort.Strings(keys) sort.Strings(keys)
contracts := make([]copilotRuntimeContract, 0, len(keys)) contracts := make([]copilotRuntimeContract, 0, len(keys))
for _, key := range keys { for _, key := range keys {
agentID := guards[key] guard := guards[key]
agentDirectory := filepath.Join(runtimeRoot, agentID) success := successes[key]
contracts = append(contracts, copilotRuntimeContract{ failure := failures[key]
agentID: agentID, if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
guardPath: filepath.Join(agentDirectory, copilotGuardScript), !sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) {
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 continue
} }
key := agentID contracts = append(contracts, copilotRuntimeContract{
if runtime.GOOS == "windows" { agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
key = strings.ToLower(agentID) auditPath: success[0].scriptPath,
})
} }
result[key] = agentID return contracts, nil
}
return result
} }
@@ -0,0 +1,277 @@
package plugins
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
type copilotRuntimePaths struct {
runtimeRoot string
agentDirectory string
configPath string
keyPath string
guardPath string
auditPath string
}
type preparedCopilotInstallation struct {
changes []*fileChange
preconditions []filePrecondition
paths *copilotRuntimePaths
}
func validateCopilotInstallConfig(config InstallConfig) error {
for _, field := range []struct{ name, value string }{
{"agent name", config.AgentName},
{"organization ID", config.OrgID},
{"agent ID", config.AgentID},
{"key ID", config.KID},
{"private key", config.PrivateKey},
{"base URL", config.BaseURL},
{"guard script path", config.GuardScriptPath},
} {
if field.value == "" {
return fmt.Errorf("%s is required", field.name)
}
}
if config.AgentName != copilotAgentKey {
return fmt.Errorf(
"GitHub Copilot CLI installation requires agent name %s",
copilotAgentKey,
)
}
if strings.TrimSpace(config.OrgID) == "" {
return fmt.Errorf("organization ID is required")
}
if strings.TrimSpace(config.KID) == "" {
return fmt.Errorf("key ID is required")
}
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
return fmt.Errorf("token must contain a non-whitespace value when provided")
}
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
return err
}
return validateManagedBaseURL(config.BaseURL)
}
func copilotAgentPaths(config InstallConfig) (*copilotRuntimePaths, error) {
if err := validateCopilotInstallConfig(config); err != nil {
return nil, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return nil, err
}
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
if err != nil {
return nil, err
}
paths := &copilotRuntimePaths{
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
configPath: filepath.Join(agentDirectory, "config.json"),
keyPath: filepath.Join(agentDirectory, "private.key"),
guardPath: filepath.Join(agentDirectory, copilotGuardScript),
auditPath: filepath.Join(agentDirectory, copilotAuditScript),
}
if !filepath.IsAbs(config.GuardScriptPath) ||
!sameCopilotPath(config.GuardScriptPath, paths.guardPath) {
return nil, fmt.Errorf(
"Elydora guard runtime must use the managed agent directory: %s",
paths.guardPath,
)
}
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
!sameCopilotPath(config.HookScript, paths.auditPath)) {
return nil, fmt.Errorf(
"Elydora audit runtime must use the managed agent directory: %s",
paths.auditPath,
)
}
return paths, nil
}
func preflightCopilotInstallation(
config InstallConfig,
sources *copilotSources,
) (*copilotRuntimePaths, string, error) {
if sources == nil || sources.user == nil || sources.user.filePath == "" {
return nil, "", fmt.Errorf(
"GitHub Copilot CLI installation requires a user hook path",
)
}
if err := requireCopilotHooksEnabled(sources); err != nil {
return nil, "", err
}
paths, err := copilotAgentPaths(config)
if err != nil {
return nil, "", err
}
if err := validateCopilotRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
return nil, "", err
}
nodePath, err := resolveNodeRuntime()
if err != nil {
return nil, "", err
}
if !filepath.IsAbs(nodePath) || !isCopilotNodeExecutable(nodePath) {
return nil, "", fmt.Errorf(
"GitHub Copilot CLI hooks require an absolute Node.js executable path",
)
}
hookSources := []copilotHooks{sources.user.hooks}
if sources.legacy != nil {
hookSources = append(hookSources, sources.legacy.hooks)
}
if err := validateCopilotJavaScriptMatchers(hookSources, nodePath); err != nil {
return nil, "", err
}
return paths, nodePath, nil
}
func buildCopilotRuntimeConfig(config InstallConfig) ([]byte, error) {
value := map[string]any{
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
"base_url": config.BaseURL, "agent_name": copilotAgentKey,
}
if config.Token != "" {
value["token"] = config.Token
}
encoded, err := json.MarshalIndent(value, "", " ")
if err != nil {
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
}
encoded = append(encoded, '\n')
if len(encoded) > maxRuntimeConfigBytes {
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
}
return encoded, nil
}
func validateCopilotRenderedSet(
sources *copilotSources,
rendered []*copilotRenderedDocument,
) error {
expected := []*copilotDocument{sources.user}
if sources.legacy != nil {
expected = append(expected, sources.legacy)
}
if len(rendered) != len(expected) {
return fmt.Errorf("GitHub Copilot rendered source set is incomplete")
}
for _, document := range expected {
matches := 0
for _, item := range rendered {
if item != nil && item.document != nil &&
sameCopilotPath(item.document.filePath, document.filePath) {
matches++
}
}
if matches != 1 {
return fmt.Errorf(
"GitHub Copilot rendered source set contains unexpected paths",
)
}
}
return nil
}
func copilotInstallationPreconditions(
sources *copilotSources,
rendered []*copilotRenderedDocument,
) []filePrecondition {
result := make([]filePrecondition, 0, len(sources.settingsPreconditions)+len(rendered))
for _, item := range sources.settingsPreconditions {
result = append(result, filePrecondition{
filePath: item.filePath, label: item.label,
snapshot: item.snapshot, maximumSize: maxManagedSourceBytes,
})
}
for _, item := range rendered {
if item != nil && !item.changed {
result = append(result, filePrecondition{
filePath: item.document.filePath,
label: "GitHub Copilot hook source",
snapshot: item.document.snapshot,
maximumSize: maxManagedSourceBytes,
})
}
}
return result
}
func prepareCopilotInstallation(
config InstallConfig,
sources *copilotSources,
rendered []*copilotRenderedDocument,
) (*preparedCopilotInstallation, error) {
paths, _, err := preflightCopilotInstallation(config, sources)
if err != nil {
return nil, err
}
if err := validateCopilotRenderedSet(sources, rendered); err != nil {
return nil, err
}
runtimeConfig, err := buildCopilotRuntimeConfig(config)
if err != nil {
return nil, err
}
items := []struct {
path, label string
content []byte
mode os.FileMode
}{
{
paths.guardPath, "Elydora guard runtime",
[]byte(generateGuardScript(copilotAgentKey, config.AgentID, "", false, "")), 0700,
},
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
{
paths.auditPath, "Elydora audit runtime",
[]byte(buildHookScriptWithOutput(copilotAgentKey, config.AgentID, "", false, true)), 0700,
},
}
changes := make([]*fileChange, 0, len(items)+len(rendered))
for _, item := range items {
change, changeErr := prepareFileChange(item.path, item.label, item.content, item.mode)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
for _, document := range rendered {
change, changeErr := prepareRenderedCopilotChange(document)
if changeErr != nil {
return nil, changeErr
}
changes = append(changes, change)
}
return &preparedCopilotInstallation{
changes: changes, paths: paths,
preconditions: copilotInstallationPreconditions(sources, rendered),
}, nil
}
func commitCopilotInstallation(
prepared *preparedCopilotInstallation,
rename renameFunc,
) error {
if prepared == nil || prepared.paths == nil {
return fmt.Errorf("prepared GitHub Copilot installation is required")
}
return writeCopilotChanges(
prepared.changes,
"Install GitHub Copilot hooks",
rename,
prepared.paths.runtimeRoot,
prepared.paths.agentDirectory,
prepared.preconditions,
)
}
+203 -250
View File
@@ -1,141 +1,219 @@
package plugins package plugins
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
func copilotConfigPaths() (string, string, error) { type copilotSettingsLayer struct {
filePath string
label string
jsonc bool
}
type copilotDirectoryLocation struct {
path string
label string
}
type copilotPaths struct {
copilotHome string
userHooksDirectory string
userHookPath string
legacyHookPath string
settingsLayers []copilotSettingsLayer
directories []copilotDirectoryLocation
}
type parsedCopilotSettings struct {
layer copilotSettingsLayer
disabled *bool
snapshot *managedFileSnapshot
}
func resolveCopilotPaths() (*copilotPaths, error) {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve home directory: %w", err) return nil, fmt.Errorf("resolve home directory: %w", err)
} }
copilotHome := strings.TrimSpace(os.Getenv("COPILOT_HOME")) override := os.Getenv("COPILOT_HOME")
if copilotHome == "" { copilotHome := override
if strings.TrimSpace(override) == "" {
copilotHome = filepath.Join(home, ".copilot") copilotHome = filepath.Join(home, ".copilot")
} else if !filepath.IsAbs(copilotHome) { } else if !filepath.IsAbs(copilotHome) {
copilotHome, err = filepath.Abs(copilotHome) copilotHome, err = filepath.Abs(copilotHome)
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve COPILOT_HOME: %w", err) return nil, fmt.Errorf("resolve COPILOT_HOME at %s: %w", override, err)
} }
} }
workingDirectory, err := os.Getwd() project, err := os.Getwd()
if err != nil { if err != nil {
return "", "", fmt.Errorf("resolve working directory: %w", err) return nil, fmt.Errorf("resolve working directory: %w", err)
} }
return filepath.Join(copilotHome, "hooks", copilotConfigFile), github := filepath.Join(project, ".github")
filepath.Join(workingDirectory, ".github", "hooks", "hooks.json"), nil githubCopilot := filepath.Join(github, "copilot")
} githubHooks := filepath.Join(github, "hooks")
claude := filepath.Join(project, ".claude")
func readCopilotHooks(value any, label string) (copilotHooks, error) { userHooks := filepath.Join(copilotHome, "hooks")
if value == nil { return &copilotPaths{
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) copilotHome: copilotHome,
} userHooksDirectory: userHooks,
object, ok := value.(map[string]any) userHookPath: filepath.Join(userHooks, copilotConfigFile),
if !ok { legacyHookPath: filepath.Join(githubHooks, "hooks.json"),
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) settingsLayers: []copilotSettingsLayer{
} {filepath.Join(copilotHome, "config.json"), "legacy Copilot user config", false},
hooks := make(copilotHooks, len(object)) {filepath.Join(copilotHome, "settings.json"), "Copilot user settings", true},
for event, handlerValue := range object { {filepath.Join(claude, "settings.json"), "Claude repository settings", true},
values, ok := handlerValue.([]any) {filepath.Join(claude, "settings.local.json"), "Claude local settings", true},
if !ok { {filepath.Join(githubCopilot, "settings.json"), "Copilot repository settings", true},
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event) {filepath.Join(githubCopilot, "settings.local.json"), "Copilot local settings", true},
} },
handlers := make([]map[string]any, 0, len(values)) directories: []copilotDirectoryLocation{
for index, value := range values { {project, "Copilot working directory"},
handler, ok := value.(map[string]any) {copilotHome, "COPILOT_HOME"},
if !ok || handler == nil { {userHooks, "Copilot user hooks directory"},
return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index) {github, "GitHub configuration directory"},
} {githubHooks, "GitHub repository hooks directory"},
handlers = append(handlers, handler) {githubCopilot, "Copilot repository settings directory"},
} {claude, "Claude repository settings directory"},
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 }, nil
} }
func readCopilotDocument(filePath, label string) (*copilotDocument, error) { func inspectCopilotDirectories(locations []copilotDirectoryLocation) error {
info, err := os.Lstat(filePath) for _, location := range locations {
if errors.Is(err, os.ErrNotExist) { if _, err := managedPhysicalDirectoryExists(location.path, location.label); err != nil {
return nil, nil return err
} }
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
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) { func readCopilotDocument(filePath, label string) (*copilotDocument, error) {
userPath, legacyPath, err := copilotConfigPaths() snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil { if err != nil || snapshot == nil {
return nil, err return nil, err
} }
user, err := readCopilotDocument(userPath, "GitHub Copilot user hooks") return parseCopilotDocument(filePath, snapshot, label)
if err != nil { }
return nil, err
func parseCopilotSettings(
snapshot *managedFileSnapshot,
layer copilotSettingsLayer,
) (map[string]any, error) {
if len(strings.TrimSpace(string(snapshot.contents))) == 0 {
return map[string]any{}, nil
} }
legacy, err := readCopilotDocument(legacyPath, "GitHub Copilot project hooks") label := fmt.Sprintf("%s at %s", layer.label, layer.filePath)
if layer.jsonc {
return decodeJSONCObject(snapshot.contents, label, true)
}
return decodeStrictJSONObject(snapshot.contents, label)
}
func readCopilotSettingsLayer(
layer copilotSettingsLayer,
) (parsedCopilotSettings, error) {
snapshot, err := readManagedFile(
layer.filePath,
layer.label,
maxManagedSourceBytes,
)
if err != nil || snapshot == nil {
return parsedCopilotSettings{layer: layer}, err
}
root, err := parseCopilotSettings(snapshot, layer)
if err != nil { if err != nil {
return nil, err return parsedCopilotSettings{}, err
}
result := parsedCopilotSettings{layer: layer, snapshot: snapshot}
if value, exists := root["disableAllHooks"]; exists {
disabled, ok := value.(bool)
if !ok {
return parsedCopilotSettings{}, fmt.Errorf(
`%s at %s field "disableAllHooks" must be a boolean`,
layer.label,
layer.filePath,
)
}
result.disabled = &disabled
}
return result, nil
}
func effectiveCopilotDisabledSource(layers []parsedCopilotSettings) string {
disabledBy := ""
for _, layer := range layers {
if layer.disabled == nil {
continue
}
if *layer.disabled {
disabledBy = fmt.Sprintf("%s at %s", layer.layer.label, layer.layer.filePath)
} else {
disabledBy = ""
}
}
return disabledBy
}
func readCopilotSources() (*copilotSources, *copilotPaths, error) {
paths, err := resolveCopilotPaths()
if err != nil {
return nil, nil, err
}
if err := inspectCopilotDirectories(paths.directories); err != nil {
return nil, nil, err
}
user, err := readCopilotDocument(paths.userHookPath, "GitHub Copilot user hooks")
if err != nil {
return nil, nil, err
}
legacy, err := readCopilotDocument(
paths.legacyHookPath,
"GitHub Copilot legacy project hooks",
)
if err != nil {
return nil, nil, err
}
settings := make([]parsedCopilotSettings, 0, len(paths.settingsLayers))
for _, layer := range paths.settingsLayers {
parsed, readErr := readCopilotSettingsLayer(layer)
if readErr != nil {
return nil, nil, readErr
}
settings = append(settings, parsed)
} }
if user == nil { if user == nil {
user = &copilotDocument{ user = createCopilotDocument(paths.userHookPath)
filePath: userPath,
root: map[string]any{},
hooks: copilotHooks{},
} }
disabledBy := effectiveCopilotDisabledSource(settings)
if user.hooksDisabled {
disabledBy = fmt.Sprintf("GitHub Copilot user hooks at %s", paths.userHookPath)
} }
return &copilotSources{user: user, legacy: legacy}, nil preconditions := make([]copilotSourcePrecondition, 0, len(settings))
for _, item := range settings {
preconditions = append(preconditions, copilotSourcePrecondition{
filePath: item.layer.filePath,
label: item.layer.label,
snapshot: item.snapshot,
})
}
return &copilotSources{
user: user, legacy: legacy, disabledBy: disabledBy,
settingsPreconditions: preconditions,
}, paths, nil
}
func requireCopilotHooksEnabled(sources *copilotSources) error {
if sources.disabledBy == "" {
return nil
}
return fmt.Errorf(
"GitHub Copilot hooks are disabled by %s; set disableAllHooks to false before installation",
sources.disabledBy,
)
} }
func prepareRenderedCopilotChange( func prepareRenderedCopilotChange(
@@ -144,166 +222,41 @@ func prepareRenderedCopilotChange(
if rendered == nil || !rendered.changed { if rendered == nil || !rendered.changed {
return nil, nil return nil, nil
} }
return prepareSourceChange( return prepareSnapshotSourceChange(
rendered.document.filePath, rendered.document.filePath,
"GitHub Copilot hook source", "GitHub Copilot hook source",
rendered.document.raw, rendered.document.snapshot,
rendered.document.exists,
rendered.next, rendered.next,
0600, 0600,
rendered.remove, rendered.remove,
) )
} }
func prepareCopilotInstallationChanges( func writeCopilotChanges(
config InstallConfig, changes []*fileChange,
label string,
rename renameFunc,
runtimeRoot string,
agentDirectory string, agentDirectory string,
auditPath string, preconditions []filePrecondition,
rendered []*copilotRenderedDocument, ) error {
) ([]*fileChange, error) { hasChanges := false
baseURL := config.BaseURL for _, change := range changes {
if baseURL == "" { if change != nil {
baseURL = "https://api.elydora.com" hasChanges = true
break
} }
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 !hasChanges {
if len(runtimeConfig) > maxRuntimeConfigBytes { return writeChanges(changes, label, rename, preconditions...)
return nil, fmt.Errorf(
"Elydora runtime config exceeds %d bytes after JSON encoding",
maxRuntimeConfigBytes,
)
} }
items := []struct { if agentDirectory != "" {
path, label string if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
content []byte return err
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)) if err := EnsurePrivateDirectory(agentDirectory); err != nil {
for _, item := range items { return err
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) return writeChanges(changes, label, rename, preconditions...)
}
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,109 @@
package plugins
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
func TestOfficialCopilot1071LoadsAllManagedHooks(t *testing.T) {
entry := os.Getenv("ELYDORA_COPILOT_ENTRY")
runtimeEntry := os.Getenv("ELYDORA_COPILOT_RUNTIME_ENTRY")
nodePath, nodeErr := exec.LookPath("node")
if entry == "" || runtimeEntry == "" || nodeErr != nil {
t.Skip(
"set ELYDORA_COPILOT_ENTRY and ELYDORA_COPILOT_RUNTIME_ENTRY to official package files",
)
}
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
environment := append(os.Environ(),
"HOME="+fixture.homeDir,
"USERPROFILE="+fixture.homeDir,
"COPILOT_HOME="+fixture.copilotHome,
)
version := exec.Command(nodePath, entry, "--version") // #nosec G204 -- test paths are explicit environment inputs.
version.Dir = fixture.projectDir
version.Env = environment
versionOutput, err := version.CombinedOutput()
if err != nil || !strings.Contains(string(versionOutput), "GitHub Copilot CLI 1.0.71.") {
t.Fatalf("official Copilot version = %q, %v", versionOutput, err)
}
source := `
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const runtime = require(process.env.ELYDORA_COPILOT_RUNTIME_ENTRY);
const session = await runtime.hookSessionCreate({
cwd: process.env.ELYDORA_PROJECT,
repoRoot: process.env.ELYDORA_PROJECT,
sessionId: 'elydora-go-official-test',
settingsJson: '{}',
userHooksDir: process.env.ELYDORA_HOOKS,
allowLocalhost: false,
allowHttpAuthHooks: false,
discoverPolicies: false,
});
try {
const snapshot = JSON.parse(await runtime.hookSessionSnapshot(session.handle));
console.log(JSON.stringify({ load: session.load, snapshot }));
} finally {
runtime.hookSessionDispose(session.handle);
}
`
load := exec.Command( // #nosec G204 -- nodePath is resolved through exec.LookPath.
nodePath,
"--input-type=module",
"--eval",
source,
)
load.Dir = fixture.projectDir
load.Env = append(environment,
"ELYDORA_COPILOT_RUNTIME_ENTRY="+runtimeEntry,
"ELYDORA_HOOKS="+filepath.Dir(fixture.configPath),
"ELYDORA_PROJECT="+fixture.projectDir,
)
loaded, err := load.CombinedOutput()
if err != nil {
t.Fatalf("load official Copilot hooks: %s: %v", loaded, err)
}
var result map[string]any
if err := json.Unmarshal(loaded, &result); err != nil {
t.Fatalf("decode official Copilot loader output %q: %v", loaded, err)
}
loadResult := requireCopilotObject(t, result["load"])
if loadResult["hookCount"] != float64(3) ||
len(requireCopilotArray(t, loadResult["errors"])) != 0 ||
len(requireCopilotArray(t, loadResult["warnings"])) != 0 {
t.Fatalf("official Copilot load result = %#v", loadResult)
}
snapshot := requireCopilotObject(t, result["snapshot"])
hooks := requireCopilotArray(t, snapshot["hooks"])
events := make([]string, 0, len(hooks))
for _, value := range hooks {
hook := requireCopilotObject(t, value)
events = append(events, stringValue(hook["eventName"]))
if filepath.Base(stringValue(hook["source"])) != copilotConfigFile {
t.Fatalf("official Copilot hook source = %#v", hook["source"])
}
var spec map[string]any
if err := json.Unmarshal([]byte(stringValue(hook["specJson"])), &spec); err != nil {
t.Fatalf("decode official hook spec: %v", err)
}
config := requireCopilotObject(t, spec["config"])
if config["type"] != "command" || config["timeoutSec"] != copilotHookTimeout ||
!strings.Contains(strings.ToLower(stringValue(config["bash"])), "node") ||
!strings.HasPrefix(stringValue(config["powershell"]), "& ") {
t.Fatalf("official Copilot hook spec = %#v", spec)
}
}
sort.Strings(events)
want := []string{"postToolUse", "postToolUseFailure", "preToolUse"}
if strings.Join(events, ",") != strings.Join(want, ",") {
t.Fatalf("official Copilot events = %#v, want %#v", events, want)
}
}
@@ -64,42 +64,46 @@ func TestCopilotInstallRejectsInvalidProjectSourceBeforeWrites(t *testing.T) {
assertCopilotRuntimeAbsent(t, fixture) assertCopilotRuntimeAbsent(t, fixture)
} }
func TestCopilotInstallRejectsMissingOrUnmanagedGuardBeforeWrites(t *testing.T) { func TestCopilotInstallGeneratesManagedGuardAndRejectsUnmanagedPath(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{}) fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
unmanaged := filepath.Join(fixture.homeDir, "unmanaged-guard.js") installCopilotFixture(t, fixture)
if err := os.WriteFile(unmanaged, []byte("process.exit(2);\n"), 0700); err != nil { expected := generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")
t.Fatalf("write unmanaged guard: %v", err) actual, err := os.ReadFile(fixture.guardPath)
if err != nil || string(actual) != expected {
t.Fatalf("generated guard = %q, %v", actual, err)
} }
fixture.config.GuardScriptPath = unmanaged
fixture = prepareCopilotFixture(t, copilotFixtureOptions{})
fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "unmanaged-guard.js")
if err := fixture.plugin.Install(fixture.config); err == nil { if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted an unmanaged guard runtime") t.Fatal("install accepted an unmanaged guard runtime")
} }
assertCopilotRuntimeAbsent(t, fixture) assertCopilotRuntimeAbsent(t, fixture)
})
} }
func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) { func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) {
t.Run("guard runtime", func(t *testing.T) { t.Run("guard runtime", func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
target := filepath.Join(fixture.homeDir, "guard-target.js") target := filepath.Join(fixture.homeDir, "guard-target.js")
if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil { if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil {
t.Fatalf("write guard target: %v", err) t.Fatalf("write guard target: %v", err)
} }
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime directory: %v", err)
}
if err := os.Symlink(target, fixture.guardPath); err != nil { if err := os.Symlink(target, fixture.guardPath); err != nil {
t.Skipf("create guard symlink: %v", err) t.Skipf("create guard symlink: %v", err)
} }
if err := fixture.plugin.Install(fixture.config); err == nil { if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted a symlink guard runtime") t.Fatal("install accepted a symlink guard runtime")
} }
assertCopilotRuntimeAbsent(t, fixture) 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)
}
}
}) })
t.Run("user source", func(t *testing.T) { t.Run("user source", func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
@@ -248,7 +252,9 @@ func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) {
func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) { func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) {
t.Helper() t.Helper()
for _, path := range []string{fixture.runtimeConfig, fixture.privateKey, fixture.hookPath} { for _, path := range []string{
fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("runtime file exists at %s: %v", path, err) t.Fatalf("runtime file exists at %s: %v", path, err)
} }
@@ -0,0 +1,351 @@
package plugins
import (
"bytes"
"context"
"encoding/json"
"fmt"
"math"
"net/url"
"os"
"os/exec"
"strings"
"time"
)
const copilotRegexValidationTimeout = 10 * time.Second
var copilotSupportedEvents = stringSet(
"agentStop",
"Stop",
"errorOccurred",
"ErrorOccurred",
"notification",
"Notification",
"permissionRequest",
"PermissionRequest",
"postToolUse",
"PostToolUse",
"postToolUseFailure",
"PostToolUseFailure",
"preCompact",
"PreCompact",
"preToolUse",
"PreToolUse",
"sessionEnd",
"SessionEnd",
"sessionStart",
"SessionStart",
"subagentStart",
"SubagentStart",
"subagentStop",
"SubagentStop",
"userPromptSubmitted",
"UserPromptSubmit",
"userPromptTransformed",
)
const copilotRegexValidator = `import fs from "node:fs";
const entries = JSON.parse(fs.readFileSync(0, "utf8"));
for (const entry of entries) {
try {
new RegExp("^(?:" + entry.pattern + ")$");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(entry.label + ": " + message);
process.exit(1);
}
}
`
type copilotMatcherEntry struct {
Label string `json:"label"`
Pattern string `json:"pattern"`
}
func copilotFieldLabel(label, field string) string {
return fmt.Sprintf(`%s field %q`, label, field)
}
func requireCopilotString(value any, label string) (string, error) {
text, ok := value.(string)
if !ok || text == "" {
return "", fmt.Errorf("%s must be a non-empty string", label)
}
return text, nil
}
func validateCopilotOptionalString(
handler map[string]any,
field string,
label string,
) error {
value, exists := handler[field]
if exists {
if _, ok := value.(string); !ok {
return fmt.Errorf("%s must be a string", copilotFieldLabel(label, field))
}
}
return nil
}
func validateCopilotTimeouts(handler map[string]any, label string) error {
for _, field := range []string{"timeout", "timeoutSec"} {
value, exists := handler[field]
if !exists {
continue
}
number, ok := value.(float64)
if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 {
return fmt.Errorf(
"%s must be a positive number",
copilotFieldLabel(label, field),
)
}
}
return nil
}
func validateCopilotMatcher(
handler map[string]any,
label string,
) error {
value, exists := handler["matcher"]
if !exists {
return nil
}
_, err := requireCopilotString(value, copilotFieldLabel(label, "matcher"))
return err
}
func validateCopilotStringMap(value any, label string) error {
object, ok := value.(map[string]any)
if !ok || object == nil {
return fmt.Errorf("%s must be an object", label)
}
for key, item := range object {
if _, err := requireCopilotString(item, label+"."+key); err != nil {
return err
}
}
return nil
}
func validateCopilotCommand(
handler map[string]any,
label string,
) error {
hasCommand := false
for _, field := range []string{"bash", "powershell", "command"} {
if _, exists := handler[field]; exists {
hasCommand = true
}
}
if !hasCommand {
return fmt.Errorf("%s must define bash, powershell, or command", label)
}
for _, field := range []string{"bash", "powershell", "command", "cwd"} {
if err := validateCopilotOptionalString(handler, field, label); err != nil {
return err
}
}
if value, exists := handler["env"]; exists {
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "env")); err != nil {
return err
}
}
if err := validateCopilotTimeouts(handler, label); err != nil {
return err
}
return validateCopilotMatcher(handler, label)
}
func copilotLoopbackHost(hostname string) bool {
return hostname == "localhost" || hostname == "::1" ||
strings.HasPrefix(hostname, "127.")
}
func validateCopilotHTTP(
handler map[string]any,
label string,
) error {
rawURL, err := requireCopilotString(
handler["url"],
copilotFieldLabel(label, "url"),
)
if err != nil {
return err
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" || parsed.Hostname() == "" {
return fmt.Errorf("%s is invalid", copilotFieldLabel(label, "url"))
}
localhostAllowed := os.Getenv("COPILOT_HOOK_ALLOW_LOCALHOST") == "1" &&
parsed.Scheme == "http" && copilotLoopbackHost(parsed.Hostname())
if parsed.Scheme != "https" && !localhostAllowed {
return fmt.Errorf("%s must use HTTPS", copilotFieldLabel(label, "url"))
}
if value, exists := handler["headers"]; exists {
if err := validateCopilotStringMap(value, copilotFieldLabel(label, "headers")); err != nil {
return err
}
}
if value, exists := handler["allowedEnvVars"]; exists {
items, ok := value.([]any)
if !ok {
return fmt.Errorf(
"%s must be an array of strings",
copilotFieldLabel(label, "allowedEnvVars"),
)
}
for _, item := range items {
if text, ok := item.(string); !ok || text == "" {
return fmt.Errorf(
"%s must be an array of strings",
copilotFieldLabel(label, "allowedEnvVars"),
)
}
}
}
if err := validateCopilotTimeouts(handler, label); err != nil {
return err
}
return validateCopilotMatcher(handler, label)
}
func validateCopilotPrompt(
handler map[string]any,
event string,
label string,
) error {
if event != "sessionStart" && event != "SessionStart" {
return fmt.Errorf("%s prompt hooks are supported only for sessionStart", label)
}
_, err := requireCopilotString(handler["prompt"], copilotFieldLabel(label, "prompt"))
return err
}
func validateCopilotHandler(
handler map[string]any,
event string,
label string,
) error {
handlerType := "command"
if value, exists := handler["type"]; exists {
var ok bool
handlerType, ok = value.(string)
if !ok {
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
}
}
switch handlerType {
case "command":
return validateCopilotCommand(handler, label)
case "http":
return validateCopilotHTTP(handler, label)
case "prompt":
return validateCopilotPrompt(handler, event, label)
default:
return fmt.Errorf("%s is unsupported", copilotFieldLabel(label, "type"))
}
}
func validateCopilotHooks(value any, label string) (copilotHooks, error) {
object, ok := value.(map[string]any)
if !ok || object == nil {
return nil, fmt.Errorf(`%s field "hooks" must be an object`, label)
}
hooks := make(copilotHooks, len(object))
for event, candidate := range object {
if _, supported := copilotSupportedEvents[event]; !supported {
return nil, fmt.Errorf("%s hook event %q is unsupported", label, event)
}
values, ok := candidate.([]any)
if !ok {
return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event)
}
handlers := make([]map[string]any, 0, len(values))
for index, value := range values {
handler, ok := value.(map[string]any)
itemLabel := fmt.Sprintf("%s handler hooks.%s[%d]", label, event, index)
if !ok || handler == nil {
return nil, fmt.Errorf("%s must be an object", itemLabel)
}
if err := validateCopilotHandler(handler, event, itemLabel); err != nil {
return nil, err
}
handlers = append(handlers, cloneCopilotObject(handler))
}
hooks[event] = handlers
}
return hooks, nil
}
func copilotMatcherEntries(hooks copilotHooks) []copilotMatcherEntry {
entries := make([]copilotMatcherEntry, 0)
wildcardEvents := stringSet(
"preToolUse", "PreToolUse", "permissionRequest", "PermissionRequest",
)
for event, handlers := range hooks {
for index, handler := range handlers {
matcher, ok := handler["matcher"].(string)
if !ok {
continue
}
if _, wildcardEvent := wildcardEvents[event]; wildcardEvent &&
(matcher == "*" || matcher == "**") {
continue
}
entries = append(entries, copilotMatcherEntry{
Label: fmt.Sprintf("GitHub Copilot hooks.%s[%d] matcher", event, index),
Pattern: matcher,
})
}
}
return entries
}
func validateCopilotJavaScriptMatchers(
sources []copilotHooks,
nodePath string,
) error {
entries := make([]copilotMatcherEntry, 0)
for _, hooks := range sources {
entries = append(entries, copilotMatcherEntries(hooks)...)
}
if len(entries) == 0 {
return nil
}
input, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("encode GitHub Copilot matchers: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), copilotRegexValidationTimeout)
defer cancel()
command := exec.CommandContext( // #nosec G204 -- nodePath is resolved through exec.LookPath.
ctx,
nodePath,
"--input-type=module",
"--eval",
copilotRegexValidator,
)
command.Stdin = bytes.NewReader(input)
output, runErr := command.CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf(
"GitHub Copilot matcher validation timed out after %s",
copilotRegexValidationTimeout,
)
}
if runErr != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = runErr.Error()
}
return fmt.Errorf(
"GitHub Copilot matcher must be a valid JavaScript regular expression: %s",
message,
)
}
return nil
}
@@ -0,0 +1,156 @@
package plugins
import (
"errors"
"os"
"strings"
"testing"
)
func TestCopilotSettingsPrecedenceAllowsLaterFalseAndPreservesJSONC(t *testing.T) {
userSource := "{\n // user policy\n \"disableAllHooks\": true,\n}\n"
claudeSource := "{\n \"disableAllHooks\": true,\n}\n"
repositorySource := "{\n \"disableAllHooks\": false,\n}\n"
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotString(userSource),
claudeLocalSettingsRaw: copilotString(claudeSource),
repositorySettingsRaw: copilotString(repositorySource),
})
installCopilotFixture(t, fixture)
assertCopilotFileEquals(t, fixture.userSettings, userSource)
assertCopilotFileEquals(t, fixture.claudeLocalSettings, claudeSource)
assertCopilotFileEquals(t, fixture.repositorySettings, repositorySource)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("Copilot status = %#v, %v", status, err)
}
}
func TestCopilotEffectiveDisabledSettingsRejectBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
options copilotFixtureOptions
want string
}{
{
name: "repository",
options: copilotFixtureOptions{
repositorySettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
},
want: "repository settings",
},
{
name: "local repository",
options: copilotFixtureOptions{
claudeLocalSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
localSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
},
want: "local settings",
},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, testCase.options)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hook source exists after rejection: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotInvalidSettingsRemainUnchanged(t *testing.T) {
for name, source := range map[string]string{
"malformed": "{ malformed",
"duplicate": `{"disableAllHooks":true,"disableAllHooks":false}`,
"invalid boolean": `{"disableAllHooks":"yes"}`,
} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted invalid GitHub Copilot settings")
}
assertCopilotFileEquals(t, fixture.userSettings, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotRejectsCompleteInvalidHookSchemaBeforeWrites(t *testing.T) {
tests := map[string]string{
"duplicate key": `{"version":1,"version":1,"hooks":{}}`,
"boolean version": `{"version":true,"hooks":{}}`,
"unknown event": `{"version":1,"hooks":{"unknownEvent":[]}}`,
"empty command": `{"version":1,"hooks":{"preToolUse":[{}]}}`,
"invalid command": `{"version":1,"hooks":{"preToolUse":[{"command":1}]}}`,
"zero timeout": `{"version":1,"hooks":{"preToolUse":[{"command":"x","timeoutSec":0}]}}`,
"prompt event": `{"version":1,"hooks":{"postToolUse":[{"type":"prompt","prompt":"x"}]}}`,
"insecure HTTP": `{"version":1,"hooks":{"postToolUse":[{"type":"http","url":"http://example.com"}]}}`,
"invalid matcher": `{"version":1,"hooks":{"preToolUse":[{"command":"x","matcher":"["}]}}`,
}
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 hook document")
}
assertCopilotFileEquals(t, fixture.configPath, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotMatchersUseJavaScriptRegularExpressionSyntax(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{
"preToolUse": []any{map[string]any{
"type": "command", "command": "user-pre", "matcher": "(?<tool>shell)",
}},
},
}),
})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
pre := requireCopilotArray(t, hooks["preToolUse"])
if requireCopilotObject(t, pre[0])["matcher"] != "(?<tool>shell)" {
t.Fatalf("preserved matcher = %#v", pre[0])
}
}
func TestCopilotUninstallRemainsAvailableWithoutNodeMatcherValidation(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
pre := requireCopilotArray(t, hooks["preToolUse"])
pre = append(pre, map[string]any{
"type": "command", "command": "user-pre", "matcher": "[",
})
hooks["preToolUse"] = pre
writeCopilotObject(t, fixture.configPath, settings)
t.Setenv("PATH", "")
if err := fixture.plugin.Uninstall(copilotTestAgentID); err != nil {
t.Fatalf("uninstall GitHub Copilot hooks: %v", err)
}
remaining := readCopilotObject(t, fixture.configPath)
remainingHooks := requireCopilotObject(t, remaining["hooks"])
if len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 1 {
t.Fatalf("remaining hooks = %#v", remainingHooks)
}
}
@@ -0,0 +1,261 @@
package plugins
import (
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
)
func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireCopilotRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateCopilotRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameCopilotAgentID(agentID, expectedAgentID) ||
config["agent_name"] != copilotAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Copilot hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != copilotAgentKey ||
!sameCopilotAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Copilot agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameCopilotPath(
contract.guardPath,
filepath.Join(agentDirectory, copilotGuardScript),
) &&
sameCopilotPath(
contract.auditPath,
filepath.Join(agentDirectory, copilotAuditScript),
), nil
}
func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) {
validPaths, err := validCopilotContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(
copilotAgentKey,
contract.agentID,
"",
false,
"",
))
expectedAudit := []byte(buildHookScriptWithOutput(
copilotAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := copilotRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
}
+49 -7
View File
@@ -18,6 +18,11 @@ func TestCopilotRegistryUsesNativeUserHooks(t *testing.T) {
if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) { if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) {
t.Fatalf("Copilot registry = %#v, want %#v", got, want) t.Fatalf("Copilot registry = %#v, want %#v", got, want)
} }
plugin := NewPlugin(copilotAgentKey)
manager, ok := plugin.(GuardRuntimeManager)
if !ok || !manager.ManagesGuardRuntime() {
t.Fatal("Copilot must own its generated guard transactionally")
}
} }
func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) { func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testing.T) {
@@ -48,7 +53,8 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin
} }
preHooks := requireCopilotArray(t, hooks["preToolUse"]) preHooks := requireCopilotArray(t, hooks["preToolUse"])
postHooks := requireCopilotArray(t, hooks["postToolUse"]) postHooks := requireCopilotArray(t, hooks["postToolUse"])
if len(preHooks) != 2 || len(postHooks) != 1 { failureHooks := requireCopilotArray(t, hooks["postToolUseFailure"])
if len(preHooks) != 2 || len(postHooks) != 1 || len(failureHooks) != 1 {
t.Fatalf("Copilot hooks = %#v", hooks) t.Fatalf("Copilot hooks = %#v", hooks)
} }
if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" { if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" {
@@ -60,6 +66,9 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin
assertNativeCopilotHandler(t, managedCopilotTestHandler( assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "postToolUse", copilotAuditScript, t, settings, "postToolUse", copilotAuditScript,
)) ))
assertNativeCopilotHandler(t, managedCopilotTestHandler(
t, settings, "postToolUseFailure", copilotAuditScript,
))
legacy := readCopilotObject(t, fixture.legacyPath) legacy := readCopilotObject(t, fixture.legacyPath)
legacyHooks := requireCopilotObject(t, legacy["hooks"]) legacyHooks := requireCopilotObject(t, legacy["hooks"])
if len(legacyHooks) != 1 || legacyHooks["notification"] == nil { if len(legacyHooks) != 1 || legacyHooks["notification"] == nil {
@@ -98,6 +107,13 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture) installCopilotFixture(t, fixture)
capturePath := filepath.Join(t.TempDir(), "captured-event.json") capturePath := filepath.Join(t.TempDir(), "captured-event.json")
guardScript := "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(fixture.guardPath, []byte(guardScript), 0700); err != nil {
t.Fatalf("write blocking guard runtime: %v", err)
}
captureScript := "const fs = require('node:fs');\n" + captureScript := "const fs = require('node:fs');\n" +
"fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n" "fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n"
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
@@ -110,7 +126,7 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") { if guardResult.exitCode != 2 || !strings.Contains(guardResult.stderr, "Agent is frozen by Elydora") {
t.Fatalf("guard result = %#v", guardResult) t.Fatalf("guard result = %#v", guardResult)
} }
postPayload := `{"sessionId":"session-1","timestamp":2,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"toolResult":{"output":"ok"}}` + "\n" postPayload := `{"sessionId":"session-1","timestamp":2,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"toolResult":{"resultType":"success","textResultForLlm":"ok"}}` + "\n"
audit := managedCopilotTestHandler(t, settings, "postToolUse", copilotAuditScript) audit := managedCopilotTestHandler(t, settings, "postToolUse", copilotAuditScript)
auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath) auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath)
if auditResult.exitCode != 0 { if auditResult.exitCode != 0 {
@@ -120,6 +136,16 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) {
if err != nil || string(captured) != postPayload { if err != nil || string(captured) != postPayload {
t.Fatalf("captured payload = %q, %v", captured, err) t.Fatalf("captured payload = %q, %v", captured, err)
} }
failurePayload := `{"sessionId":"session-1","timestamp":3,"cwd":"project","toolName":"powershell","toolArgs":{"command":"Get-ChildItem"},"error":"command failed"}` + "\n"
failure := managedCopilotTestHandler(t, settings, "postToolUseFailure", copilotAuditScript)
failureResult := runCopilotHandler(t, failure, failurePayload, "ELYDORA_CAPTURE="+capturePath)
if failureResult.exitCode != 0 {
t.Fatalf("failure audit result = %#v", failureResult)
}
captured, err = os.ReadFile(capturePath)
if err != nil || string(captured) != failurePayload {
t.Fatalf("captured failure payload = %q, %v", captured, err)
}
} }
func TestCopilotEmptyHomeOverrideUsesOfficialDefault(t *testing.T) { func TestCopilotEmptyHomeOverrideUsesOfficialDefault(t *testing.T) {
@@ -146,7 +172,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
settings := readCopilotObject(t, fixture.configPath) settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"]) hooks := requireCopilotObject(t, settings["hooks"])
delete(hooks, "postToolUse") delete(hooks, "postToolUseFailure")
writeCopilotObject(t, fixture.configPath, settings) writeCopilotObject(t, fixture.configPath, settings)
status, err = fixture.plugin.Status() status, err = fixture.plugin.Status()
if err != nil || status.HookConfigured || status.Installed { if err != nil || status.HookConfigured || status.Installed {
@@ -177,7 +203,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) {
runtimeConfig["agent_id"] = "another-agent" runtimeConfig["agent_id"] = "another-agent"
writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig) writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig)
status, err = fixture.plugin.Status() status, err = fixture.plugin.Status()
if err != nil || status.HookScriptExists || status.Installed { if err == nil || status.HookScriptExists || status.Installed {
t.Fatalf("identity mismatch status = %#v, %v", status, err) t.Fatalf("identity mismatch status = %#v, %v", status, err)
} }
} }
@@ -186,9 +212,24 @@ func TestCopilotStatusRecognizesActiveProjectDelivery(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil)) writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil))
writeCopilotObject(t, fixture.runtimeConfig, map[string]any{ writeCopilotObject(t, fixture.runtimeConfig, map[string]any{
"agent_id": copilotTestAgentID, "agent_name": copilotAgentKey, "org_id": "org-1", "agent_id": copilotTestAgentID, "kid": "kid-1",
"base_url": "https://api.elydora.test", "agent_name": copilotAgentKey,
}) })
if err := os.WriteFile(fixture.hookPath, []byte("process.stdin.resume();\n"), 0700); err != nil { if err := os.WriteFile(fixture.privateKey, []byte(copilotTestPrivateKey), 0600); err != nil {
t.Fatalf("write private key: %v", err)
}
if err := os.WriteFile(
fixture.guardPath,
[]byte(generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "")),
0700,
); err != nil {
t.Fatalf("write guard runtime: %v", err)
}
if err := os.WriteFile(
fixture.hookPath,
[]byte(buildHookScriptWithOutput(copilotAgentKey, copilotTestAgentID, "", false, true)),
0700,
); err != nil {
t.Fatalf("write audit runtime: %v", err) t.Fatalf("write audit runtime: %v", err)
} }
@@ -237,7 +278,8 @@ func TestCopilotUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing
remaining := readCopilotObject(t, fixture.configPath) remaining := readCopilotObject(t, fixture.configPath)
remainingHooks := requireCopilotObject(t, remaining["hooks"]) remainingHooks := requireCopilotObject(t, remaining["hooks"])
if remainingHooks["postToolUse"] != nil || len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 2 { if remainingHooks["postToolUse"] != nil || remainingHooks["postToolUseFailure"] != nil ||
len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 2 {
t.Fatalf("remaining hooks = %#v", remainingHooks) t.Fatalf("remaining hooks = %#v", remainingHooks)
} }
if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 { if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 {
@@ -12,12 +12,20 @@ import (
"testing" "testing"
) )
const copilotTestAgentID = "agent-1" const (
copilotTestAgentID = "agent-1"
copilotTestPrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws"
)
type copilotFixtureOptions struct { type copilotFixtureOptions struct {
userRaw *string userRaw *string
legacyRaw *string legacyRaw *string
skipGuard bool legacyUserConfigRaw *string
userSettingsRaw *string
claudeSettingsRaw *string
claudeLocalSettingsRaw *string
repositorySettingsRaw *string
localSettingsRaw *string
emptyOverride bool emptyOverride bool
} }
@@ -34,6 +42,12 @@ type copilotFixture struct {
hookPath string hookPath string
runtimeConfig string runtimeConfig string
privateKey string privateKey string
legacyUserConfig string
userSettings string
claudeSettings string
claudeLocalSettings string
repositorySettings string
localSettings string
} }
type copilotCommandResult struct { type copilotCommandResult struct {
@@ -63,24 +77,27 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID) agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID)
configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile) configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile)
legacyPath := filepath.Join(projectDir, ".github", "hooks", "hooks.json") legacyPath := filepath.Join(projectDir, ".github", "hooks", "hooks.json")
legacyUserConfig := filepath.Join(copilotHome, "config.json")
userSettings := filepath.Join(copilotHome, "settings.json")
claudeSettings := filepath.Join(projectDir, ".claude", "settings.json")
claudeLocalSettings := filepath.Join(projectDir, ".claude", "settings.local.json")
repositorySettings := filepath.Join(projectDir, ".github", "copilot", "settings.json")
localSettings := filepath.Join(projectDir, ".github", "copilot", "settings.local.json")
guardPath := filepath.Join(agentDir, copilotGuardScript) guardPath := filepath.Join(agentDir, copilotGuardScript)
hookPath := filepath.Join(agentDir, copilotAuditScript) hookPath := filepath.Join(agentDir, copilotAuditScript)
for _, directory := range []string{projectDir, agentDir} { for _, directory := range []string{homeDir, projectDir} {
if err := os.MkdirAll(directory, 0700); err != nil { if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatalf("create fixture directory %s: %v", directory, err) 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, configPath, options.userRaw)
writeOptionalCopilotFile(t, legacyPath, options.legacyRaw) writeOptionalCopilotFile(t, legacyPath, options.legacyRaw)
writeOptionalCopilotFile(t, legacyUserConfig, options.legacyUserConfigRaw)
writeOptionalCopilotFile(t, userSettings, options.userSettingsRaw)
writeOptionalCopilotFile(t, claudeSettings, options.claudeSettingsRaw)
writeOptionalCopilotFile(t, claudeLocalSettings, options.claudeLocalSettingsRaw)
writeOptionalCopilotFile(t, repositorySettings, options.repositorySettingsRaw)
writeOptionalCopilotFile(t, localSettings, options.localSettingsRaw)
t.Setenv("HOME", homeDir) t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir) t.Setenv("USERPROFILE", homeDir)
if options.emptyOverride { if options.emptyOverride {
@@ -113,9 +130,15 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot
hookPath: hookPath, hookPath: hookPath,
runtimeConfig: filepath.Join(agentDir, "config.json"), runtimeConfig: filepath.Join(agentDir, "config.json"),
privateKey: filepath.Join(agentDir, "private.key"), privateKey: filepath.Join(agentDir, "private.key"),
legacyUserConfig: legacyUserConfig,
userSettings: userSettings,
claudeSettings: claudeSettings,
claudeLocalSettings: claudeLocalSettings,
repositorySettings: repositorySettings,
localSettings: localSettings,
config: InstallConfig{ config: InstallConfig{
AgentName: copilotAgentKey, OrgID: "org-1", AgentID: copilotTestAgentID, AgentName: copilotAgentKey, OrgID: "org-1", AgentID: copilotTestAgentID,
PrivateKey: "test-key", KID: "kid-1", Token: "token-1", PrivateKey: copilotTestPrivateKey, KID: "kid-1", Token: "token-1",
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
}, },
} }
@@ -218,6 +241,10 @@ func legacyCopilotConfig(fixture *copilotFixture, extraHooks map[string]any) map
"type": "command", "bash": "node " + fixture.hookPath, "type": "command", "bash": "node " + fixture.hookPath,
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout, "powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
}}, }},
"postToolUseFailure": []any{map[string]any{
"type": "command", "bash": "node " + fixture.hookPath,
"powershell": "node " + fixture.hookPath, "timeoutSec": copilotLegacyTimeout,
}},
} }
for key, value := range extraHooks { for key, value := range extraHooks {
hooks[key] = value hooks[key] = value
@@ -0,0 +1,248 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCopilotPrepareRejectsSameContentStaleHookSnapshot(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{"notification": []any{}},
}),
})
sources, _, err := readCopilotSources()
if err != nil {
t.Fatalf("read GitHub Copilot sources: %v", err)
}
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
if err != nil {
t.Fatalf("preflight GitHub Copilot installation: %v", err)
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
t.Fatalf("render GitHub Copilot installation: %v", err)
}
original, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read user hooks: %v", err)
}
replacement := fixture.configPath + ".replacement"
if err := os.WriteFile(replacement, original, 0600); err != nil {
t.Fatalf("write replacement hooks: %v", err)
}
if err := os.Rename(replacement, fixture.configPath); err != nil {
t.Fatalf("replace user hooks: %v", err)
}
_, err = prepareCopilotInstallation(fixture.config, sources, rendered)
if err == nil || !strings.Contains(err.Error(), "changed before update") {
t.Fatalf("prepare error = %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotInstallRollsBackWhenEffectiveSettingsChange(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
})
commits := 0
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if err := os.Rename(source, destination); err != nil {
return err
}
if !mutated && strings.HasSuffix(source, ".tmp") {
commits++
if commits == 3 {
mutated = true
writeCopilotObject(t, fixture.userSettings, map[string]any{
"disableAllHooks": true,
})
}
}
return nil
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
t.Fatalf("install error = %v", err)
}
settings := readCopilotObject(t, fixture.userSettings)
if settings["disableAllHooks"] != true {
t.Fatalf("concurrent settings = %#v", settings)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hook source exists after rollback: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir)
}
func TestCopilotIdempotentCommitChecksSameContentSettingsIdentity(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
})
installCopilotFixture(t, fixture)
sources, _, err := readCopilotSources()
if err != nil {
t.Fatalf("read GitHub Copilot sources: %v", err)
}
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
if err != nil {
t.Fatalf("preflight GitHub Copilot installation: %v", err)
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
t.Fatalf("render GitHub Copilot installation: %v", err)
}
prepared, err := prepareCopilotInstallation(fixture.config, sources, rendered)
if err != nil {
t.Fatalf("prepare GitHub Copilot installation: %v", err)
}
original, err := os.ReadFile(fixture.userSettings)
if err != nil {
t.Fatalf("read user settings: %v", err)
}
replacement := fixture.userSettings + ".replacement"
if err := os.WriteFile(replacement, original, 0600); err != nil {
t.Fatalf("write replacement settings: %v", err)
}
if err := os.Rename(replacement, fixture.userSettings); err != nil {
t.Fatalf("replace user settings: %v", err)
}
err = commitCopilotInstallation(prepared, fixture.plugin.rename)
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
t.Fatalf("commit error = %v", err)
}
}
func TestCopilotInstallPreservesOrphanedRuntimeWithoutIdentity(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime directory: %v", err)
}
if err := os.WriteFile(fixture.hookPath, []byte("orphaned\n"), 0700); err != nil {
t.Fatalf("write orphaned runtime: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "cannot be verified") {
t.Fatalf("install error = %v", err)
}
current, readErr := os.ReadFile(fixture.hookPath)
if readErr != nil || string(current) != "orphaned\n" {
t.Fatalf("orphaned runtime = %q, %v", current, readErr)
}
for _, path := range []string{
fixture.configPath, fixture.guardPath, fixture.runtimeConfig, fixture.privateKey,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected file at %s: %v", path, err)
}
}
}
func TestCopilotInstallRejectsLinkedHomeAndRuntimeDirectories(t *testing.T) {
for _, location := range []string{"home", "runtime"} {
t.Run(location, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), location+"-target")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create symlink target: %v", err)
}
linkPath := fixture.copilotHome
if location == "runtime" {
linkPath = fixture.agentDir
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
t.Fatalf("create runtime root: %v", err)
}
}
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("create directory symlink: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("install error = %v", err)
}
})
}
}
func TestCopilotStatusRequiresExactRuntimeSourcesAndPrivateKey(t *testing.T) {
for _, name := range []string{"guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
path := fixture.guardPath
if name == "audit" {
path = fixture.hookPath
}
if err := os.WriteFile(path, []byte("// tampered\n"), 0700); err != nil {
t.Fatalf("tamper %s runtime: %v", name, err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("tampered %s status = %#v, %v", name, status, err)
}
})
}
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "canonical 32-byte") {
t.Fatalf("invalid key status error = %v", err)
}
}
func TestCopilotValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
edit func(*copilotFixture)
want string
}{
{"agent name", func(f *copilotFixture) { f.config.AgentName = "codex" }, "requires agent name copilot"},
{"organization", func(f *copilotFixture) { f.config.OrgID = " " }, "organization ID is required"},
{"key ID", func(f *copilotFixture) { f.config.KID = "\t" }, "key ID is required"},
{"token", func(f *copilotFixture) { f.config.Token = " " }, "token must contain"},
{"private key", func(f *copilotFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"base URL", func(f *copilotFixture) {
f.config.BaseURL = "https://api.elydora.com/path?token=secret"
}, "query parameters"},
{"guard path", func(f *copilotFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed agent directory"},
{"audit path", func(f *copilotFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed agent directory"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
testCase.edit(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
+23 -1
View File
@@ -100,7 +100,12 @@ func prepareSourceChange(
}, nil }, nil
} }
func writeChanges(changes []*fileChange, label string, rename renameFunc) error { func writeChanges(
changes []*fileChange,
label string,
rename renameFunc,
preconditions ...filePrecondition,
) error {
filtered := make([]fileChange, 0, len(changes)) filtered := make([]fileChange, 0, len(changes))
targets := map[string]bool{} targets := map[string]bool{}
for _, change := range changes { for _, change := range changes {
@@ -117,6 +122,9 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
targets[target] = true targets[target] = true
filtered = append(filtered, *change) filtered = append(filtered, *change)
} }
if err := assertFilePreconditions(preconditions, label); err != nil {
return fmt.Errorf("%s: %w", label, err)
}
if len(filtered) == 0 { if len(filtered) == 0 {
return nil return nil
} }
@@ -132,13 +140,27 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error
} }
staged = append(staged, item) staged = append(staged, item)
} }
if err := assertFilePreconditions(preconditions, label); err != nil {
cleanupErrors := cleanupStaging(staged)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), cleanupErrors, "cleanup failed")
}
for index := range staged { for index := range staged {
if err := assertFilePreconditions(preconditions, label); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
if err := commitChange(&staged[index], rename); err != nil { if err := commitChange(&staged[index], rename); err != nil {
recoveryErrors := rollbackChanges(staged, rename) recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed") return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
} }
} }
if err := assertFilePreconditions(preconditions, label); err != nil {
recoveryErrors := rollbackChanges(staged, rename)
recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...)
return joinTransactionFailure(fmt.Errorf("%s: %w", label, err), recoveryErrors, "recovery failed")
}
cleanupErrors := cleanupStaging(staged) cleanupErrors := cleanupStaging(staged)
if len(cleanupErrors) > 0 { if len(cleanupErrors) > 0 {
return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed") return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed")
@@ -0,0 +1,95 @@
package plugins
import (
"bytes"
"fmt"
"os"
)
type filePrecondition struct {
filePath string
label string
snapshot *managedFileSnapshot
maximumSize int64
}
func prepareSnapshotSourceChange(
filePath, label string,
original *managedFileSnapshot,
next []byte,
mode os.FileMode,
remove bool,
) (*fileChange, error) {
if !remove && int64(len(next)) > maxManagedSourceBytes {
return nil, fmt.Errorf(
"%s exceeds %d bytes: %s",
label,
maxManagedSourceBytes,
filePath,
)
}
current, err := readManagedFile(filePath, label, maxManagedSourceBytes)
if err != nil {
return nil, err
}
if !sameManagedSnapshot(current, original) {
return nil, fmt.Errorf("%s changed before update: %s", label, filePath)
}
if original == nil && (remove || next == nil) {
return nil, nil
}
if original != nil && !remove && bytes.Equal(original.contents, next) {
return nil, nil
}
change := &fileChange{
filePath: filePath,
label: label,
next: append([]byte(nil), next...),
mode: mode,
remove: remove,
}
if original != nil {
change.original = append([]byte(nil), original.contents...)
change.originalInfo = original.info
change.originalMode = original.mode
change.existed = true
}
return change, nil
}
func sameManagedSnapshot(current, expected *managedFileSnapshot) bool {
if current == nil || expected == nil {
return current == expected
}
return bytes.Equal(current.contents, expected.contents) &&
os.SameFile(current.info, expected.info)
}
func assertFilePreconditions(
preconditions []filePrecondition,
operation string,
) error {
for _, condition := range preconditions {
maximumSize := condition.maximumSize
if maximumSize <= 0 {
maximumSize = maxManagedSourceBytes
}
current, err := readManagedFile(
condition.filePath,
condition.label,
maximumSize,
)
if err != nil {
return err
}
if !sameManagedSnapshot(current, condition.snapshot) {
return fmt.Errorf(
"%s changed during %s: %s",
condition.label,
operation,
condition.filePath,
)
}
}
return nil
}