From 42a7653a771f0cee913e74d144469369841bfb66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 13:03:27 -0400 Subject: [PATCH] fix(go): mirror official Copilot hook contract --- sdks/go/README.md | 2 +- sdks/go/cmd/elydora/plugins/copilot.go | 265 ++++++---- .../cmd/elydora/plugins/copilot_contract.go | 276 +++++++--- .../elydora/plugins/copilot_installation.go | 277 ++++++++++ sdks/go/cmd/elydora/plugins/copilot_io.go | 473 ++++++++---------- .../elydora/plugins/copilot_official_test.go | 109 ++++ .../elydora/plugins/copilot_recovery_test.go | 52 +- sdks/go/cmd/elydora/plugins/copilot_schema.go | 351 +++++++++++++ .../elydora/plugins/copilot_schema_test.go | 156 ++++++ sdks/go/cmd/elydora/plugins/copilot_status.go | 261 ++++++++++ sdks/go/cmd/elydora/plugins/copilot_test.go | 56 ++- .../plugins/copilot_test_helpers_test.go | 105 ++-- .../plugins/copilot_transaction_test.go | 248 +++++++++ sdks/go/cmd/elydora/plugins/transaction.go | 24 +- .../plugins/transaction_precondition.go | 95 ++++ 15 files changed, 2241 insertions(+), 509 deletions(-) create mode 100644 sdks/go/cmd/elydora/plugins/copilot_installation.go create mode 100644 sdks/go/cmd/elydora/plugins/copilot_official_test.go create mode 100644 sdks/go/cmd/elydora/plugins/copilot_schema.go create mode 100644 sdks/go/cmd/elydora/plugins/copilot_schema_test.go create mode 100644 sdks/go/cmd/elydora/plugins/copilot_status.go create mode 100644 sdks/go/cmd/elydora/plugins/copilot_transaction_test.go create mode 100644 sdks/go/cmd/elydora/plugins/transaction_precondition.go diff --git a/sdks/go/README.md b/sdks/go/README.md index ab596e7..39af692 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -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. -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. diff --git a/sdks/go/cmd/elydora/plugins/copilot.go b/sdks/go/cmd/elydora/plugins/copilot.go index 1243705..44a3bb0 100644 --- a/sdks/go/cmd/elydora/plugins/copilot.go +++ b/sdks/go/cmd/elydora/plugins/copilot.go @@ -2,7 +2,6 @@ package plugins import ( "fmt" - "os" "path/filepath" ) @@ -11,131 +10,214 @@ type CopilotPlugin struct { rename renameFunc } -func (p *CopilotPlugin) Install(config InstallConfig) error { - if config.AgentID == "" { - return fmt.Errorf("agent ID is required") - } - sources, err := readCopilotSources() +// ManagesGuardRuntime reports that Copilot commits its provider guard with the +// audit runtime and hook documents. +func (p *CopilotPlugin) ManagesGuardRuntime() bool { + return true +} + +// PreflightInstall validates hook sources, settings, matchers, credentials, +// and runtime identity before any write. +func (p *CopilotPlugin) PreflightInstall(config InstallConfig) error { + 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, + _, _, 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), ) } - runtimeRoot, err := AgentRuntimeRoot() + user, err := renderCopilotDocument(sources.user, hooks) if err != nil { - return err + return nil, fmt.Errorf("render GitHub Copilot user hooks: %w", 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, "") - userHooks["preToolUse"] = append( - userHooks["preToolUse"], - buildCopilotHandler(nodePath, expectedGuard), - ) - userHooks["postToolUse"] = append( - userHooks["postToolUse"], - buildCopilotHandler(nodePath, auditPath), - ) - userRendered, err := renderCopilotDocument(sources.user, userHooks) - if err != nil { - return fmt.Errorf("render GitHub Copilot user hooks: %w", err) - } - rendered := []*copilotRenderedDocument{userRendered} + rendered := []*copilotRenderedDocument{user} if sources.legacy != nil { - legacyHooks := removeManagedCopilotHooks(sources.legacy.hooks, runtimeRoot, "") - legacyRendered, renderErr := renderCopilotDocument(sources.legacy, legacyHooks) - if renderErr != nil { - return fmt.Errorf("render GitHub Copilot project hooks: %w", renderErr) + legacyHooks, removeErr := removeManagedCopilotHooks(sources.legacy.hooks, "") + if removeErr != nil { + return nil, removeErr } - rendered = append(rendered, legacyRendered) + legacy, renderErr := renderCopilotDocument(sources.legacy, legacyHooks) + if renderErr != nil { + return nil, fmt.Errorf("render GitHub Copilot legacy project hooks: %w", renderErr) + } + rendered = append(rendered, legacy) } - changes, err := prepareCopilotInstallationChanges( - config, - agentDirectory, - auditPath, - rendered, + return rendered, nil +} + +func (p *CopilotPlugin) Install(config InstallConfig) error { + 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 { 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 } - 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 } -func (p *CopilotPlugin) Uninstall(agentID string) error { - sources, err := readCopilotSources() - if err != nil { - return err - } - runtimeRoot, err := AgentRuntimeRoot() - if err != nil { - return err - } +func renderCopilotUninstall( + sources *copilotSources, + agentID string, +) ([]*copilotRenderedDocument, error) { documents := []*copilotDocument{sources.user} if sources.legacy != nil { documents = append(documents, sources.legacy) } - changes := make([]*fileChange, 0, len(documents)) + rendered := make([]*copilotRenderedDocument, 0, len(documents)) for _, document := range documents { - hooks := removeManagedCopilotHooks(document.hooks, runtimeRoot, agentID) - rendered, renderErr := renderCopilotDocument(document, hooks) - if renderErr != nil { - return fmt.Errorf("render GitHub Copilot hook source: %w", renderErr) + hooks, err := removeManagedCopilotHooks(document.hooks, agentID) + if err != nil { + return nil, err } - 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 { return changeErr } 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) { - userPath, _, pathErr := copilotConfigPaths() + paths, pathErr := resolveCopilotPaths() entry := SupportedAgents[copilotAgentKey] - status := PluginStatus{ - AgentName: copilotAgentKey, DisplayName: entry.Name, ConfigPath: userPath, - } + status := PluginStatus{AgentName: copilotAgentKey, DisplayName: entry.Name} if pathErr != nil { return status, pathErr } - sources, err := readCopilotSources() + status.ConfigPath = paths.userHookPath + sources, _, err := readCopilotSources() if err != nil { return status, err } - runtimeRoot, err := AgentRuntimeRoot() + contracts, err := mergedCopilotContracts(sources) if err != nil { return status, err } - contracts := activeCopilotContracts(sources, runtimeRoot) - status.ConfigPath = configuredCopilotPath(sources, runtimeRoot) - status.HookConfigured = len(contracts) > 0 + status.ConfigPath, err = configuredCopilotPath(sources, paths.userHookPath) + if err != nil { + return status, err + } + status.HookConfigured = sources.disabledBy == "" && len(contracts) > 0 if !status.HookConfigured { return status, nil } @@ -146,20 +228,3 @@ func (p *CopilotPlugin) Status() (PluginStatus, error) { status.Installed = status.HookScriptExists 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 -} diff --git a/sdks/go/cmd/elydora/plugins/copilot_contract.go b/sdks/go/cmd/elydora/plugins/copilot_contract.go index 9607121..fa6a74a 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_contract.go +++ b/sdks/go/cmd/elydora/plugins/copilot_contract.go @@ -1,7 +1,9 @@ package plugins import ( + "bytes" "encoding/json" + "fmt" "path/filepath" "runtime" "sort" @@ -18,20 +20,38 @@ const ( copilotPOSIXApostrophe = `'"'"'` ) +var copilotManagedEvents = []struct { + event string + script string +}{ + {"preToolUse", copilotGuardScript}, + {"postToolUse", copilotAuditScript}, + {"postToolUseFailure", copilotAuditScript}, +} + type copilotHooks map[string][]map[string]any type copilotDocument struct { - exists bool + exists bool + filePath string + root map[string]any + hooks copilotHooks + raw []byte + snapshot *managedFileSnapshot + hooksDisabled bool +} + +type copilotSourcePrecondition struct { filePath string - root map[string]any - hooks copilotHooks - raw []byte - disabled bool + label string + snapshot *managedFileSnapshot } type copilotSources struct { - user *copilotDocument - legacy *copilotDocument + user *copilotDocument + legacy *copilotDocument + disabledBy string + settingsPreconditions []copilotSourcePrecondition } type copilotRenderedDocument struct { @@ -47,10 +67,25 @@ type copilotRuntimeContract struct { 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 { clone := make(copilotHooks, len(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 } @@ -77,6 +112,13 @@ func sameCopilotAgentID(left, right string) bool { 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 { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } @@ -198,40 +240,47 @@ func copilotManagedScriptPath(handler map[string]any) (string, bool) { return parseCopilotLegacyCommand(bash) } -func copilotManagedAgentID( +func copilotManagedEntryForHandler( handler map[string]any, scriptName string, - runtimeRoot string, -) (string, bool) { +) (*copilotManagedEntry, error) { scriptPath, managed := copilotManagedScriptPath(handler) - if !managed || !filepath.IsAbs(scriptPath) || filepath.Base(scriptPath) != scriptName { - return "", false + if !managed || !filepath.IsAbs(scriptPath) || + !sameCopilotFileName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err } agentDirectory := filepath.Dir(scriptPath) if !sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) { - return "", false + return nil, nil } 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( hooks copilotHooks, - runtimeRoot string, agentID string, -) copilotHooks { +) (copilotHooks, error) { result := cloneCopilotHooks(hooks) - for _, contract := range []struct{ event, script string }{ - {"preToolUse", copilotGuardScript}, - {"postToolUse", copilotAuditScript}, - } { + for _, contract := range copilotManagedEvents { kept := make([]map[string]any, 0, len(result[contract.event])) for _, handler := range result[contract.event] { - managedID, managed := copilotManagedAgentID(handler, contract.script, runtimeRoot) - if managed && (agentID == "" || sameCopilotAgentID(managedID, agentID)) { - continue + entry, err := copilotManagedEntryForHandler(handler, contract.script) + if err != nil { + 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 { delete(result, contract.event) @@ -239,7 +288,65 @@ func removeManagedCopilotHooks( 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( @@ -249,22 +356,12 @@ func renderCopilotDocument( if !document.exists && len(hooks) == 0 { return &copilotRenderedDocument{document: document}, nil } - if document.exists && len(hooks) == 0 { - owned := true - for key := range document.root { - if key != "version" && key != "hooks" { - owned = false - break - } - } - if owned { - return &copilotRenderedDocument{document: document, changed: true, remove: true}, nil - } - } - root := make(map[string]any, len(document.root)+1) - for key, value := range document.root { - root[key] = value + if document.exists && emptyOwnedCopilotDocument(document.root, hooks) { + return &copilotRenderedDocument{ + document: document, changed: true, remove: true, + }, nil } + root := cloneCopilotObject(document.root) root["version"] = float64(1) if len(hooks) == 0 { delete(root, "hooks") @@ -273,58 +370,81 @@ func renderCopilotDocument( } next, err := json.MarshalIndent(root, "", " ") if err != nil { - return nil, err + return nil, fmt.Errorf("encode GitHub Copilot hooks: %w", err) } 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{ document: document, - changed: !document.exists || string(next) != string(document.raw), + changed: !document.exists || !bytes.Equal(next, document.raw), next: next, }, nil } -func copilotRuntimeContracts( - hooks copilotHooks, - runtimeRoot string, -) []copilotRuntimeContract { - guards := managedCopilotIDs(hooks["preToolUse"], copilotGuardScript, runtimeRoot) - audits := managedCopilotIDs(hooks["postToolUse"], copilotAuditScript, runtimeRoot) +func copilotEntryKey(agentID string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(agentID) + } + return agentID +} + +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)) for key := range guards { - if _, exists := audits[key]; exists { - keys = append(keys, key) - } + keys = append(keys, key) } sort.Strings(keys) contracts := make([]copilotRuntimeContract, 0, len(keys)) for _, key := range keys { - agentID := guards[key] - agentDirectory := filepath.Join(runtimeRoot, agentID) - contracts = append(contracts, copilotRuntimeContract{ - agentID: agentID, - guardPath: filepath.Join(agentDirectory, copilotGuardScript), - auditPath: filepath.Join(agentDirectory, copilotAuditScript), - }) - } - return contracts -} - -func managedCopilotIDs( - handlers []map[string]any, - scriptName string, - runtimeRoot string, -) map[string]string { - result := map[string]string{} - for _, handler := range handlers { - agentID, managed := copilotManagedAgentID(handler, scriptName, runtimeRoot) - if !managed { + guard := guards[key] + success := successes[key] + failure := failures[key] + if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || + !sameCopilotPath(success[0].scriptPath, failure[0].scriptPath) { continue } - key := agentID - if runtime.GOOS == "windows" { - key = strings.ToLower(agentID) - } - result[key] = agentID + contracts = append(contracts, copilotRuntimeContract{ + agentID: guard[0].agentID, guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }) } - return result + return contracts, nil } diff --git a/sdks/go/cmd/elydora/plugins/copilot_installation.go b/sdks/go/cmd/elydora/plugins/copilot_installation.go new file mode 100644 index 0000000..34f6b41 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/copilot_installation.go @@ -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, + ) +} diff --git a/sdks/go/cmd/elydora/plugins/copilot_io.go b/sdks/go/cmd/elydora/plugins/copilot_io.go index 2111952..fd6f8a7 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_io.go +++ b/sdks/go/cmd/elydora/plugins/copilot_io.go @@ -1,141 +1,219 @@ package plugins import ( - "encoding/json" - "errors" "fmt" "os" "path/filepath" - "runtime" "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() 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")) - if copilotHome == "" { + override := os.Getenv("COPILOT_HOME") + copilotHome := override + if strings.TrimSpace(override) == "" { copilotHome = filepath.Join(home, ".copilot") } else if !filepath.IsAbs(copilotHome) { copilotHome, err = filepath.Abs(copilotHome) if err != nil { - return "", "", fmt.Errorf("resolve COPILOT_HOME: %w", err) + return nil, fmt.Errorf("resolve COPILOT_HOME at %s: %w", override, err) } } - workingDirectory, err := os.Getwd() + project, err := os.Getwd() 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), - filepath.Join(workingDirectory, ".github", "hooks", "hooks.json"), nil -} - -func readCopilotHooks(value any, label string) (copilotHooks, error) { - if value == nil { - return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) - } - object, ok := value.(map[string]any) - if !ok { - return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) - } - hooks := make(copilotHooks, len(object)) - for event, handlerValue := range object { - values, ok := handlerValue.([]any) - if !ok { - return nil, fmt.Errorf(`%s field "hooks.%s" must be an array`, label, event) - } - handlers := make([]map[string]any, 0, len(values)) - for index, value := range values { - handler, ok := value.(map[string]any) - if !ok || handler == nil { - return nil, fmt.Errorf(`%s handler hooks.%s[%d] must be an object`, label, event, index) - } - handlers = append(handlers, handler) - } - hooks[event] = handlers - } - return hooks, nil -} - -func parseCopilotDocument( - filePath string, - raw []byte, - label string, -) (*copilotDocument, error) { - var value any - if err := json.Unmarshal(raw, &value); err != nil { - return nil, fmt.Errorf("parse %s at %s: %w", label, filePath, err) - } - root, ok := value.(map[string]any) - if !ok || root == nil { - return nil, fmt.Errorf("%s at %s must contain a JSON object", label, filePath) - } - if root["version"] != float64(1) { - return nil, fmt.Errorf("%s at %s must declare version 1", label, filePath) - } - hooks := copilotHooks{} - if hooksValue, exists := root["hooks"]; exists { - var err error - hooks, err = readCopilotHooks(hooksValue, label) - if err != nil { - return nil, err - } - } - disabled := false - if value, exists := root["disableAllHooks"]; exists { - var valid bool - disabled, valid = value.(bool) - if !valid { - return nil, fmt.Errorf(`%s field "disableAllHooks" must be a boolean`, label) - } - } - return &copilotDocument{ - exists: true, filePath: filePath, root: root, - hooks: hooks, raw: append([]byte(nil), raw...), disabled: disabled, + github := filepath.Join(project, ".github") + githubCopilot := filepath.Join(github, "copilot") + githubHooks := filepath.Join(github, "hooks") + claude := filepath.Join(project, ".claude") + userHooks := filepath.Join(copilotHome, "hooks") + return &copilotPaths{ + copilotHome: copilotHome, + userHooksDirectory: userHooks, + userHookPath: filepath.Join(userHooks, copilotConfigFile), + legacyHookPath: filepath.Join(githubHooks, "hooks.json"), + settingsLayers: []copilotSettingsLayer{ + {filepath.Join(copilotHome, "config.json"), "legacy Copilot user config", false}, + {filepath.Join(copilotHome, "settings.json"), "Copilot user settings", true}, + {filepath.Join(claude, "settings.json"), "Claude repository settings", true}, + {filepath.Join(claude, "settings.local.json"), "Claude local settings", true}, + {filepath.Join(githubCopilot, "settings.json"), "Copilot repository settings", true}, + {filepath.Join(githubCopilot, "settings.local.json"), "Copilot local settings", true}, + }, + directories: []copilotDirectoryLocation{ + {project, "Copilot working directory"}, + {copilotHome, "COPILOT_HOME"}, + {userHooks, "Copilot user hooks directory"}, + {github, "GitHub configuration directory"}, + {githubHooks, "GitHub repository hooks directory"}, + {githubCopilot, "Copilot repository settings directory"}, + {claude, "Claude repository settings directory"}, + }, }, nil } -func readCopilotDocument(filePath, label string) (*copilotDocument, error) { - info, err := os.Lstat(filePath) - if errors.Is(err, os.ErrNotExist) { - return nil, nil - } - if err != nil { - return nil, fmt.Errorf("inspect %s at %s: %w", label, filePath, err) - } - if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return nil, fmt.Errorf("%s path is not a physical file: %s", label, filePath) - } - raw, err := os.ReadFile(filePath) // #nosec G304 -- filePath is a validated provider configuration path. - if err != nil { - return nil, fmt.Errorf("read %s at %s: %w", label, filePath, err) - } - return parseCopilotDocument(filePath, raw, label) -} - -func readCopilotSources() (*copilotSources, error) { - userPath, legacyPath, err := copilotConfigPaths() - if err != nil { - return nil, err - } - user, err := readCopilotDocument(userPath, "GitHub Copilot user hooks") - if err != nil { - return nil, err - } - legacy, err := readCopilotDocument(legacyPath, "GitHub Copilot project hooks") - if err != nil { - return nil, err - } - if user == nil { - user = &copilotDocument{ - filePath: userPath, - root: map[string]any{}, - hooks: copilotHooks{}, +func inspectCopilotDirectories(locations []copilotDirectoryLocation) error { + for _, location := range locations { + if _, err := managedPhysicalDirectoryExists(location.path, location.label); err != nil { + return err } } - return &copilotSources{user: user, legacy: legacy}, nil + return nil +} + +func readCopilotDocument(filePath, label string) (*copilotDocument, error) { + snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes) + if err != nil || snapshot == nil { + return nil, err + } + return parseCopilotDocument(filePath, snapshot, label) +} + +func parseCopilotSettings( + snapshot *managedFileSnapshot, + layer copilotSettingsLayer, +) (map[string]any, error) { + if len(strings.TrimSpace(string(snapshot.contents))) == 0 { + return map[string]any{}, nil + } + 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 { + 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 { + user = createCopilotDocument(paths.userHookPath) + } + disabledBy := effectiveCopilotDisabledSource(settings) + if user.hooksDisabled { + disabledBy = fmt.Sprintf("GitHub Copilot user hooks at %s", paths.userHookPath) + } + 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( @@ -144,166 +222,41 @@ func prepareRenderedCopilotChange( if rendered == nil || !rendered.changed { return nil, nil } - return prepareSourceChange( + return prepareSnapshotSourceChange( rendered.document.filePath, "GitHub Copilot hook source", - rendered.document.raw, - rendered.document.exists, + rendered.document.snapshot, rendered.next, 0600, rendered.remove, ) } -func prepareCopilotInstallationChanges( - config InstallConfig, +func writeCopilotChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot string, agentDirectory string, - auditPath string, - rendered []*copilotRenderedDocument, -) ([]*fileChange, error) { - baseURL := config.BaseURL - if baseURL == "" { - baseURL = "https://api.elydora.com" - } - runtimeConfig, err := json.MarshalIndent(agentRuntimeConfig{ - OrgID: config.OrgID, AgentID: config.AgentID, KID: config.KID, - BaseURL: baseURL, Token: config.Token, AgentName: copilotAgentKey, - }, "", " ") - if err != nil { - return nil, fmt.Errorf("encode Elydora runtime config: %w", err) - } - runtimeConfig = append(runtimeConfig, '\n') - if len(runtimeConfig) > maxRuntimeConfigBytes { - return nil, fmt.Errorf( - "Elydora runtime config exceeds %d bytes after JSON encoding", - maxRuntimeConfigBytes, - ) - } - items := []struct { - path, label string - content []byte - mode os.FileMode - }{ - {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, - {filepath.Join(agentDirectory, "private.key"), "Elydora private key", []byte(config.PrivateKey), 0600}, - {auditPath, "Elydora audit runtime", []byte(buildHookScript(copilotAgentKey, config.AgentID)), 0700}, - } - changes := make([]*fileChange, 0, len(items)+len(rendered)) - for _, item := range items { - if err := validateRuntimeFileTarget(item.path, item.label); err != nil { - return nil, err + preconditions []filePrecondition, +) error { + hasChanges := false + for _, change := range changes { + if change != nil { + hasChanges = true + break } - change, err := prepareFileChange(item.path, item.label, item.content, item.mode) - if err != nil { - return nil, err - } - changes = append(changes, change) } - for _, document := range rendered { - change, err := prepareRenderedCopilotChange(document) - if err != nil { - return nil, err - } - changes = append(changes, change) + if !hasChanges { + return writeChanges(changes, label, rename, preconditions...) } - 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 + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err } - 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 + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err } } - return false, nil + return writeChanges(changes, label, rename, preconditions...) } diff --git a/sdks/go/cmd/elydora/plugins/copilot_official_test.go b/sdks/go/cmd/elydora/plugins/copilot_official_test.go new file mode 100644 index 0000000..d0fcd2c --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/copilot_official_test.go @@ -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) + } +} diff --git a/sdks/go/cmd/elydora/plugins/copilot_recovery_test.go b/sdks/go/cmd/elydora/plugins/copilot_recovery_test.go index c4ee9eb..682bde8 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_recovery_test.go +++ b/sdks/go/cmd/elydora/plugins/copilot_recovery_test.go @@ -64,42 +64,46 @@ func TestCopilotInstallRejectsInvalidProjectSourceBeforeWrites(t *testing.T) { assertCopilotRuntimeAbsent(t, fixture) } -func TestCopilotInstallRejectsMissingOrUnmanagedGuardBeforeWrites(t *testing.T) { - t.Run("missing", func(t *testing.T) { - fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) - if err := fixture.plugin.Install(fixture.config); err == nil { - t.Fatal("install accepted a missing guard runtime") - } - assertCopilotRuntimeAbsent(t, fixture) - }) - t.Run("unmanaged path", func(t *testing.T) { - fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) - unmanaged := filepath.Join(fixture.homeDir, "unmanaged-guard.js") - if err := os.WriteFile(unmanaged, []byte("process.exit(2);\n"), 0700); err != nil { - t.Fatalf("write unmanaged guard: %v", err) - } - fixture.config.GuardScriptPath = unmanaged - if err := fixture.plugin.Install(fixture.config); err == nil { - t.Fatal("install accepted an unmanaged guard runtime") - } - assertCopilotRuntimeAbsent(t, fixture) - }) +func TestCopilotInstallGeneratesManagedGuardAndRejectsUnmanagedPath(t *testing.T) { + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) + installCopilotFixture(t, fixture) + expected := generateGuardScript(copilotAgentKey, copilotTestAgentID, "", false, "") + actual, err := os.ReadFile(fixture.guardPath) + if err != nil || string(actual) != expected { + t.Fatalf("generated guard = %q, %v", actual, err) + } + + fixture = prepareCopilotFixture(t, copilotFixtureOptions{}) + fixture.config.GuardScriptPath = filepath.Join(fixture.homeDir, "unmanaged-guard.js") + if err := fixture.plugin.Install(fixture.config); err == nil { + t.Fatal("install accepted an unmanaged guard runtime") + } + assertCopilotRuntimeAbsent(t, fixture) } func TestCopilotInstallRejectsSymlinkFiles(t *testing.T) { t.Run("guard runtime", func(t *testing.T) { - fixture := prepareCopilotFixture(t, copilotFixtureOptions{skipGuard: true}) + fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) target := filepath.Join(fixture.homeDir, "guard-target.js") if err := os.WriteFile(target, []byte("process.exit(2);\n"), 0700); err != nil { t.Fatalf("write guard target: %v", err) } + if err := os.MkdirAll(fixture.agentDir, 0700); err != nil { + t.Fatalf("create agent runtime directory: %v", err) + } if err := os.Symlink(target, fixture.guardPath); err != nil { t.Skipf("create guard symlink: %v", err) } if err := fixture.plugin.Install(fixture.config); err == nil { t.Fatal("install accepted a symlink guard runtime") } - assertCopilotRuntimeAbsent(t, fixture) + 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) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) @@ -248,7 +252,9 @@ func TestCopilotInstallDetectsConcurrentSourceChangeAndRollsBack(t *testing.T) { func assertCopilotRuntimeAbsent(t *testing.T, fixture *copilotFixture) { 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) { t.Fatalf("runtime file exists at %s: %v", path, err) } diff --git a/sdks/go/cmd/elydora/plugins/copilot_schema.go b/sdks/go/cmd/elydora/plugins/copilot_schema.go new file mode 100644 index 0000000..944d6fe --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/copilot_schema.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/copilot_schema_test.go b/sdks/go/cmd/elydora/plugins/copilot_schema_test.go new file mode 100644 index 0000000..6ad2448 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/copilot_schema_test.go @@ -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": "(?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"] != "(?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) + } +} diff --git a/sdks/go/cmd/elydora/plugins/copilot_status.go b/sdks/go/cmd/elydora/plugins/copilot_status.go new file mode 100644 index 0000000..734173c --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/copilot_status.go @@ -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 +} diff --git a/sdks/go/cmd/elydora/plugins/copilot_test.go b/sdks/go/cmd/elydora/plugins/copilot_test.go index 8712d1c..b631e45 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_test.go +++ b/sdks/go/cmd/elydora/plugins/copilot_test.go @@ -18,6 +18,11 @@ func TestCopilotRegistryUsesNativeUserHooks(t *testing.T) { if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(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) { @@ -48,7 +53,8 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin } preHooks := requireCopilotArray(t, hooks["preToolUse"]) 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) } if requireCopilotObject(t, preHooks[0])["command"] != "user-pre" { @@ -60,6 +66,9 @@ func TestCopilotInstallPreservesUserHooksMigratesLegacyAndIsIdempotent(t *testin assertNativeCopilotHandler(t, managedCopilotTestHandler( t, settings, "postToolUse", copilotAuditScript, )) + assertNativeCopilotHandler(t, managedCopilotTestHandler( + t, settings, "postToolUseFailure", copilotAuditScript, + )) legacy := readCopilotObject(t, fixture.legacyPath) legacyHooks := requireCopilotObject(t, legacy["hooks"]) if len(legacyHooks) != 1 || legacyHooks["notification"] == nil { @@ -98,6 +107,13 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) installCopilotFixture(t, fixture) 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" + "fs.writeFileSync(process.env.ELYDORA_CAPTURE, fs.readFileSync(0));\n" 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") { 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) auditResult := runCopilotHandler(t, audit, postPayload, "ELYDORA_CAPTURE="+capturePath) if auditResult.exitCode != 0 { @@ -120,6 +136,16 @@ func TestCopilotCommandsBlockAndForwardNativePayloadByteForByte(t *testing.T) { if err != nil || string(captured) != postPayload { 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) { @@ -146,7 +172,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) { settings := readCopilotObject(t, fixture.configPath) hooks := requireCopilotObject(t, settings["hooks"]) - delete(hooks, "postToolUse") + delete(hooks, "postToolUseFailure") writeCopilotObject(t, fixture.configPath, settings) status, err = fixture.plugin.Status() if err != nil || status.HookConfigured || status.Installed { @@ -177,7 +203,7 @@ func TestCopilotStatusRequiresEnabledPairIdentityAndRuntimeFiles(t *testing.T) { runtimeConfig["agent_id"] = "another-agent" writeCopilotObject(t, fixture.runtimeConfig, runtimeConfig) 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) } } @@ -186,9 +212,24 @@ func TestCopilotStatusRecognizesActiveProjectDelivery(t *testing.T) { fixture := prepareCopilotFixture(t, copilotFixtureOptions{}) writeCopilotObject(t, fixture.legacyPath, legacyCopilotConfig(fixture, nil)) writeCopilotObject(t, fixture.runtimeConfig, map[string]any{ - "agent_id": copilotTestAgentID, "agent_name": copilotAgentKey, + "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) } @@ -237,7 +278,8 @@ func TestCopilotUninstallRemovesExactOwnershipAndPreservesUserEntries(t *testing remaining := readCopilotObject(t, fixture.configPath) 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) } if len(requireCopilotArray(t, remainingHooks["notification"])) != 1 { diff --git a/sdks/go/cmd/elydora/plugins/copilot_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/copilot_test_helpers_test.go index 7690d5e..3c48b13 100644 --- a/sdks/go/cmd/elydora/plugins/copilot_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/copilot_test_helpers_test.go @@ -12,28 +12,42 @@ import ( "testing" ) -const copilotTestAgentID = "agent-1" +const ( + copilotTestAgentID = "agent-1" + copilotTestPrivateKey = "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws" +) type copilotFixtureOptions struct { - userRaw *string - legacyRaw *string - skipGuard bool - emptyOverride bool + userRaw *string + legacyRaw *string + legacyUserConfigRaw *string + userSettingsRaw *string + claudeSettingsRaw *string + claudeLocalSettingsRaw *string + repositorySettingsRaw *string + localSettingsRaw *string + emptyOverride bool } type copilotFixture struct { - plugin *CopilotPlugin - config InstallConfig - homeDir string - projectDir string - copilotHome string - agentDir string - configPath string - legacyPath string - guardPath string - hookPath string - runtimeConfig string - privateKey string + plugin *CopilotPlugin + config InstallConfig + homeDir string + projectDir string + copilotHome string + agentDir string + configPath string + legacyPath string + guardPath string + hookPath string + runtimeConfig string + privateKey string + legacyUserConfig string + userSettings string + claudeSettings string + claudeLocalSettings string + repositorySettings string + localSettings string } type copilotCommandResult struct { @@ -63,24 +77,27 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot agentDir := filepath.Join(homeDir, ".elydora", copilotTestAgentID) configPath := filepath.Join(copilotHome, "hooks", copilotConfigFile) 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) 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 { t.Fatalf("create fixture directory %s: %v", directory, err) } } - if !options.skipGuard { - guard := "const fs = require('node:fs');\n" + - "fs.readFileSync(0);\n" + - "process.stderr.write('Agent is frozen by Elydora.');\n" + - "process.exit(2);\n" - if err := os.WriteFile(guardPath, []byte(guard), 0700); err != nil { - t.Fatalf("write guard runtime: %v", err) - } - } writeOptionalCopilotFile(t, configPath, options.userRaw) writeOptionalCopilotFile(t, legacyPath, options.legacyRaw) + 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("USERPROFILE", homeDir) if options.emptyOverride { @@ -102,20 +119,26 @@ func prepareCopilotFixture(t *testing.T, options copilotFixtureOptions) *copilot } }) return &copilotFixture{ - plugin: &CopilotPlugin{}, - homeDir: homeDir, - projectDir: projectDir, - copilotHome: copilotHome, - agentDir: agentDir, - configPath: configPath, - legacyPath: legacyPath, - guardPath: guardPath, - hookPath: hookPath, - runtimeConfig: filepath.Join(agentDir, "config.json"), - privateKey: filepath.Join(agentDir, "private.key"), + plugin: &CopilotPlugin{}, + homeDir: homeDir, + projectDir: projectDir, + copilotHome: copilotHome, + agentDir: agentDir, + configPath: configPath, + legacyPath: legacyPath, + guardPath: guardPath, + hookPath: hookPath, + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + legacyUserConfig: legacyUserConfig, + userSettings: userSettings, + claudeSettings: claudeSettings, + claudeLocalSettings: claudeLocalSettings, + repositorySettings: repositorySettings, + localSettings: localSettings, config: InstallConfig{ 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, }, } @@ -218,6 +241,10 @@ func legacyCopilotConfig(fixture *copilotFixture, extraHooks map[string]any) map "type": "command", "bash": "node " + fixture.hookPath, "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 { hooks[key] = value diff --git a/sdks/go/cmd/elydora/plugins/copilot_transaction_test.go b/sdks/go/cmd/elydora/plugins/copilot_transaction_test.go new file mode 100644 index 0000000..8b1917b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/copilot_transaction_test.go @@ -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) + }) + } +} diff --git a/sdks/go/cmd/elydora/plugins/transaction.go b/sdks/go/cmd/elydora/plugins/transaction.go index ee52147..f984943 100644 --- a/sdks/go/cmd/elydora/plugins/transaction.go +++ b/sdks/go/cmd/elydora/plugins/transaction.go @@ -100,7 +100,12 @@ func prepareSourceChange( }, 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)) targets := map[string]bool{} for _, change := range changes { @@ -117,6 +122,9 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error targets[target] = true filtered = append(filtered, *change) } + if err := assertFilePreconditions(preconditions, label); err != nil { + return fmt.Errorf("%s: %w", label, err) + } if len(filtered) == 0 { return nil } @@ -132,13 +140,27 @@ func writeChanges(changes []*fileChange, label string, rename renameFunc) error } 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 { + 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 { recoveryErrors := rollbackChanges(staged, rename) recoveryErrors = append(recoveryErrors, cleanupStaging(staged)...) 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) if len(cleanupErrors) > 0 { return joinTransactionFailure(fmt.Errorf("%s cleanup failed", label), cleanupErrors, "cleanup failed") diff --git a/sdks/go/cmd/elydora/plugins/transaction_precondition.go b/sdks/go/cmd/elydora/plugins/transaction_precondition.go new file mode 100644 index 0000000..02c9e67 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/transaction_precondition.go @@ -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 +}