fix(copilot): implement official transactional hook contract

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 12:58:30 -04:00
1 parent 050d0043ea
commit 827374e7f8
16 files changed
+2243 -510

No files matched your search

+2 -1
View File
@@ -36,7 +36,8 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Resolve Auggie user hooks through `~/.augment/settings.json`; keep system, workspace, local workspace, and alternate `--augment-cache-dir` settings read-only. Validate the shipped Auggie 0.33 hook schema: `PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `Notification`, and `PromptSubmit`; regex matchers belong to tool events, command handlers use string-array arguments and positive millisecond timeouts, and metadata flags are booleans. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix, preserve the complete native snake_case payload, and propagate freeze and revocation through exit code `2`.
- Validate Auggie matcher syntax during installation with Node.js `new RegExp`; keep status and uninstall independent from matcher validation so recovery remains available offline. Commit Auggie settings, both wrappers, generated runtimes, runtime config, and private key through one rollback-capable transaction. Require exact runtime identity, canonical private keys, physical files, and exact wrapper sources during status checks, then verify the effective configuration with `auggie tools list`.
- Cline CLI 3.0.46 discovers exact `PreToolUse` and `PostToolUse` file basenames across `~/Documents/Cline/Hooks`, `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default, workspace `.clinerules/hooks`, and workspace `.cline/hooks`; Elydora owns the `$CLINE_DIR/hooks` pair and keeps the other roots read-only. Commit both wrappers, generated guard and audit runtimes, strict config, and canonical private key through one rollback-capable transaction. Preserve official input byte-for-byte, emit pure JSON cancellation control for guard exit code `2`, reject linked managed paths, and require exact generated sources plus runtime identity for healthy status. Cline surfaces hook errors and continues the task, so wrapper stderr and runtime `error.log` are operational evidence.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Migrate exact Elydora-owned entries from project `.github/hooks/hooks.json`, preserve native camelCase input, require `disableAllHooks` to be false, and commit runtime plus hook documents in one rollback-capable transaction.
- Write GitHub Copilot CLI hooks only to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Keep repository, Claude-compatible, inline, plugin, and policy hook sources additive; migrate only exact Elydora-owned entries from project `.github/hooks/hooks.json`. Resolve `disableAllHooks` through legacy user config, user settings, Claude repository settings, and Copilot repository settings in official precedence order; settings accept JSONC and trailing commas, while hook documents remain strict JSON.
- Validate the complete GitHub Copilot CLI event and command, HTTP, and prompt handler schema before writes, then validate matchers with Node.js regular-expression semantics during install. Own one exact `preToolUse`, `postToolUse`, and `postToolUseFailure` triple, preserve native camelCase payloads, and propagate freeze and revocation through exit code `2`. Generate guard, config, private key, and audit runtimes with both hook documents in one rollback-capable transaction guarded by read-only settings snapshots. Healthy status requires the enabled three-event contract, strict runtime identity, canonical private key, and exact generated runtime sources.
- Factory Droid `droid@0.175.0` reads direct event maps from `~/.factory/hooks.json` or the legacy `~/.factory/hooks/hooks.json`; `~/.factory/settings.json` stores the same map under `hooks`. Select the active source per event, preserve JSONC syntax, and direct users to `/hooks` for review.
- Commit Factory Droid runtime config, private key, audit runtime, and affected hook documents in one rollback-capable transaction. Status requires an enabled `PreToolUse`/`PostToolUse` pair with matching managed runtime files.
- Qwen Code `0.20.0` loads user hooks from `$QWEN_HOME/settings.json` with `~/.qwen/settings.json` as the default; follow Qwen's `.env` precedence and keep workspace settings read-only. Preserve comments, reject trailing commas and duplicate keys, express timeouts in milliseconds, and direct users to `/hooks` for review.
+1 -1
View File
@@ -87,7 +87,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.
+165 -100
View File
@@ -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
}
+198 -78
View File
@@ -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
}
+277
View File
@@ -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,
)
}
+213 -260
View File
@@ -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...)
}
@@ -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)
}
}
+29 -23
View File
@@ -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)
}
+351
View File
@@ -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
}
+156
View File
@@ -0,0 +1,156 @@
package plugins
import (
"errors"
"os"
"strings"
"testing"
)
func TestCopilotSettingsPrecedenceAllowsLaterFalseAndPreservesJSONC(t *testing.T) {
userSource := "{\n // user policy\n \"disableAllHooks\": true,\n}\n"
claudeSource := "{\n \"disableAllHooks\": true,\n}\n"
repositorySource := "{\n \"disableAllHooks\": false,\n}\n"
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotString(userSource),
claudeLocalSettingsRaw: copilotString(claudeSource),
repositorySettingsRaw: copilotString(repositorySource),
})
installCopilotFixture(t, fixture)
assertCopilotFileEquals(t, fixture.userSettings, userSource)
assertCopilotFileEquals(t, fixture.claudeLocalSettings, claudeSource)
assertCopilotFileEquals(t, fixture.repositorySettings, repositorySource)
status, err := fixture.plugin.Status()
if err != nil || !status.Installed {
t.Fatalf("Copilot status = %#v, %v", status, err)
}
}
func TestCopilotEffectiveDisabledSettingsRejectBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
options copilotFixtureOptions
want string
}{
{
name: "repository",
options: copilotFixtureOptions{
repositorySettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
},
want: "repository settings",
},
{
name: "local repository",
options: copilotFixtureOptions{
claudeLocalSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
localSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": true}),
},
want: "local settings",
},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, testCase.options)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hook source exists after rejection: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotInvalidSettingsRemainUnchanged(t *testing.T) {
for name, source := range map[string]string{
"malformed": "{ malformed",
"duplicate": `{"disableAllHooks":true,"disableAllHooks":false}`,
"invalid boolean": `{"disableAllHooks":"yes"}`,
} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted invalid GitHub Copilot settings")
}
assertCopilotFileEquals(t, fixture.userSettings, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotRejectsCompleteInvalidHookSchemaBeforeWrites(t *testing.T) {
tests := map[string]string{
"duplicate key": `{"version":1,"version":1,"hooks":{}}`,
"boolean version": `{"version":true,"hooks":{}}`,
"unknown event": `{"version":1,"hooks":{"unknownEvent":[]}}`,
"empty command": `{"version":1,"hooks":{"preToolUse":[{}]}}`,
"invalid command": `{"version":1,"hooks":{"preToolUse":[{"command":1}]}}`,
"zero timeout": `{"version":1,"hooks":{"preToolUse":[{"command":"x","timeoutSec":0}]}}`,
"prompt event": `{"version":1,"hooks":{"postToolUse":[{"type":"prompt","prompt":"x"}]}}`,
"insecure HTTP": `{"version":1,"hooks":{"postToolUse":[{"type":"http","url":"http://example.com"}]}}`,
"invalid matcher": `{"version":1,"hooks":{"preToolUse":[{"command":"x","matcher":"["}]}}`,
}
for name, source := range tests {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotString(source),
})
if err := fixture.plugin.Install(fixture.config); err == nil {
t.Fatal("install accepted an invalid GitHub Copilot hook document")
}
assertCopilotFileEquals(t, fixture.configPath, source)
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
func TestCopilotMatchersUseJavaScriptRegularExpressionSyntax(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{
"preToolUse": []any{map[string]any{
"type": "command", "command": "user-pre", "matcher": "(?<tool>shell)",
}},
},
}),
})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
pre := requireCopilotArray(t, hooks["preToolUse"])
if requireCopilotObject(t, pre[0])["matcher"] != "(?<tool>shell)" {
t.Fatalf("preserved matcher = %#v", pre[0])
}
}
func TestCopilotUninstallRemainsAvailableWithoutNodeMatcherValidation(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
settings := readCopilotObject(t, fixture.configPath)
hooks := requireCopilotObject(t, settings["hooks"])
pre := requireCopilotArray(t, hooks["preToolUse"])
pre = append(pre, map[string]any{
"type": "command", "command": "user-pre", "matcher": "[",
})
hooks["preToolUse"] = pre
writeCopilotObject(t, fixture.configPath, settings)
t.Setenv("PATH", "")
if err := fixture.plugin.Uninstall(copilotTestAgentID); err != nil {
t.Fatalf("uninstall GitHub Copilot hooks: %v", err)
}
remaining := readCopilotObject(t, fixture.configPath)
remainingHooks := requireCopilotObject(t, remaining["hooks"])
if len(requireCopilotArray(t, remainingHooks["preToolUse"])) != 1 {
t.Fatalf("remaining hooks = %#v", remainingHooks)
}
}
+261
View File
@@ -0,0 +1,261 @@
package plugins
import (
"bytes"
"fmt"
"path/filepath"
"sort"
"strings"
)
func readCopilotRuntimeConfig(path string) (map[string]any, bool, error) {
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
if err != nil || snapshot == nil {
return nil, snapshot != nil, err
}
label := fmt.Sprintf("Elydora runtime config at %s", path)
config, err := decodeStrictJSONObject(snapshot.contents, label)
return config, true, err
}
func requireCopilotRuntimeString(
config map[string]any,
field string,
configPath string,
) (string, error) {
value, ok := config[field].(string)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf(
"Elydora runtime config %s is invalid: %s",
field,
configPath,
)
}
return value, nil
}
func validateCopilotRuntimeConfig(
config map[string]any,
expectedAgentID string,
configPath string,
) error {
supported := stringSet(
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
)
fields := make([]string, 0, len(config))
for field := range config {
fields = append(fields, field)
}
sort.Strings(fields)
for _, field := range fields {
if _, ok := supported[field]; !ok {
return fmt.Errorf(
`Elydora runtime config has unsupported field %q: %s`,
field,
configPath,
)
}
}
if _, err := requireCopilotRuntimeString(config, "org_id", configPath); err != nil {
return err
}
if _, err := requireCopilotRuntimeString(config, "kid", configPath); err != nil {
return err
}
agentID, err := requireCopilotRuntimeString(config, "agent_id", configPath)
if err != nil {
return err
}
if !sameCopilotAgentID(agentID, expectedAgentID) ||
config["agent_name"] != copilotAgentKey {
return fmt.Errorf(
"Elydora runtime identity does not match Copilot hooks: %s",
configPath,
)
}
if _, exists := config["token"]; exists {
if _, err := requireCopilotRuntimeString(config, "token", configPath); err != nil {
return err
}
}
baseURL, err := requireCopilotRuntimeString(config, "base_url", configPath)
if err != nil {
return err
}
if err := validateManagedBaseURL(baseURL); err != nil {
return fmt.Errorf(
"Elydora runtime config base URL is invalid at %s: %w",
configPath,
err,
)
}
return nil
}
func validateCopilotRuntimeIdentity(agentDirectory, agentID string) error {
runtimeRoot := filepath.Dir(agentDirectory)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return err
}
configPath := filepath.Join(agentDirectory, "config.json")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return err
}
artifactExists := false
for _, item := range []struct {
path string
label string
limit int64
}{
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
{filepath.Join(agentDirectory, copilotGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, copilotAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
} {
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
if inspectErr != nil {
return inspectErr
}
artifactExists = artifactExists || exists
}
if !configExists {
if artifactExists {
return fmt.Errorf(
"Elydora runtime identity cannot be verified without config.json: %s",
agentDirectory,
)
}
return nil
}
configuredID, ok := config["agent_id"].(string)
if !ok || config["agent_name"] != copilotAgentKey ||
!sameCopilotAgentID(configuredID, agentID) {
return fmt.Errorf(
"Elydora runtime config identity does not match Copilot agent %s: %s",
agentID,
configPath,
)
}
return nil
}
func validCopilotContractPaths(contract copilotRuntimeContract) (bool, error) {
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
return sameCopilotPath(filepath.Dir(agentDirectory), runtimeRoot) &&
sameCopilotPath(
contract.guardPath,
filepath.Join(agentDirectory, copilotGuardScript),
) &&
sameCopilotPath(
contract.auditPath,
filepath.Join(agentDirectory, copilotAuditScript),
), nil
}
func copilotRuntimeContractExists(contract copilotRuntimeContract) (bool, error) {
validPaths, err := validCopilotContractPaths(contract)
if err != nil || !validPaths {
return false, err
}
runtimeRoot, err := AgentRuntimeRoot()
if err != nil {
return false, err
}
agentDirectory := filepath.Dir(contract.guardPath)
rootExists, err := managedPhysicalDirectoryExists(
runtimeRoot,
"Elydora runtime directory",
)
if err != nil || !rootExists {
return false, err
}
directoryExists, err := managedPhysicalDirectoryExists(
agentDirectory,
"Elydora agent runtime directory",
)
if err != nil || !directoryExists {
return false, err
}
configPath := filepath.Join(agentDirectory, "config.json")
keyPath := filepath.Join(agentDirectory, "private.key")
config, configExists, err := readCopilotRuntimeConfig(configPath)
if err != nil {
return false, err
}
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
if err != nil {
return false, err
}
guard, err := readManagedFile(
contract.guardPath,
"Elydora guard runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
audit, err := readManagedFile(
contract.auditPath,
"Elydora audit runtime",
maxManagedSourceBytes,
)
if err != nil {
return false, err
}
if !configExists || key == nil || guard == nil || audit == nil {
return false, nil
}
if err := validateCopilotRuntimeConfig(config, contract.agentID, configPath); err != nil {
return false, err
}
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
}
expectedGuard := []byte(generateGuardScript(
copilotAgentKey,
contract.agentID,
"",
false,
"",
))
expectedAudit := []byte(buildHookScriptWithOutput(
copilotAgentKey,
contract.agentID,
"",
false,
true,
))
return bytes.Equal(guard.contents, expectedGuard) &&
bytes.Equal(audit.contents, expectedAudit), nil
}
func copilotRuntimeFilesExist(contracts []copilotRuntimeContract) (bool, error) {
for _, contract := range contracts {
exists, err := copilotRuntimeContractExists(contract)
if err != nil {
return false, err
}
if exists {
return true, nil
}
}
return false, nil
}
+49 -7
View File
@@ -18,6 +18,11 @@ func TestCopilotRegistryUsesNativeUserHooks(t *testing.T) {
if got := SupportedAgents[copilotAgentKey]; !reflect.DeepEqual(got, want) {
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 {
@@ -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
@@ -0,0 +1,248 @@
package plugins
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCopilotPrepareRejectsSameContentStaleHookSnapshot(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userRaw: copilotJSON(map[string]any{
"version": float64(1),
"hooks": map[string]any{"notification": []any{}},
}),
})
sources, _, err := readCopilotSources()
if err != nil {
t.Fatalf("read GitHub Copilot sources: %v", err)
}
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
if err != nil {
t.Fatalf("preflight GitHub Copilot installation: %v", err)
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
t.Fatalf("render GitHub Copilot installation: %v", err)
}
original, err := os.ReadFile(fixture.configPath)
if err != nil {
t.Fatalf("read user hooks: %v", err)
}
replacement := fixture.configPath + ".replacement"
if err := os.WriteFile(replacement, original, 0600); err != nil {
t.Fatalf("write replacement hooks: %v", err)
}
if err := os.Rename(replacement, fixture.configPath); err != nil {
t.Fatalf("replace user hooks: %v", err)
}
_, err = prepareCopilotInstallation(fixture.config, sources, rendered)
if err == nil || !strings.Contains(err.Error(), "changed before update") {
t.Fatalf("prepare error = %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
}
func TestCopilotInstallRollsBackWhenEffectiveSettingsChange(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
})
commits := 0
mutated := false
fixture.plugin.rename = func(source, destination string) error {
if err := os.Rename(source, destination); err != nil {
return err
}
if !mutated && strings.HasSuffix(source, ".tmp") {
commits++
if commits == 3 {
mutated = true
writeCopilotObject(t, fixture.userSettings, map[string]any{
"disableAllHooks": true,
})
}
}
return nil
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
t.Fatalf("install error = %v", err)
}
settings := readCopilotObject(t, fixture.userSettings)
if settings["disableAllHooks"] != true {
t.Fatalf("concurrent settings = %#v", settings)
}
if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("hook source exists after rollback: %v", err)
}
assertCopilotRuntimeAbsent(t, fixture)
assertNoCopilotTransactionArtifacts(t, fixture.homeDir, fixture.projectDir)
}
func TestCopilotIdempotentCommitChecksSameContentSettingsIdentity(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{
userSettingsRaw: copilotJSON(map[string]any{"disableAllHooks": false}),
})
installCopilotFixture(t, fixture)
sources, _, err := readCopilotSources()
if err != nil {
t.Fatalf("read GitHub Copilot sources: %v", err)
}
paths, nodePath, err := preflightCopilotInstallation(fixture.config, sources)
if err != nil {
t.Fatalf("preflight GitHub Copilot installation: %v", err)
}
rendered, err := renderCopilotInstallation(
sources,
paths.guardPath,
paths.auditPath,
nodePath,
)
if err != nil {
t.Fatalf("render GitHub Copilot installation: %v", err)
}
prepared, err := prepareCopilotInstallation(fixture.config, sources, rendered)
if err != nil {
t.Fatalf("prepare GitHub Copilot installation: %v", err)
}
original, err := os.ReadFile(fixture.userSettings)
if err != nil {
t.Fatalf("read user settings: %v", err)
}
replacement := fixture.userSettings + ".replacement"
if err := os.WriteFile(replacement, original, 0600); err != nil {
t.Fatalf("write replacement settings: %v", err)
}
if err := os.Rename(replacement, fixture.userSettings); err != nil {
t.Fatalf("replace user settings: %v", err)
}
err = commitCopilotInstallation(prepared, fixture.plugin.rename)
if err == nil || !strings.Contains(err.Error(), "user settings changed") {
t.Fatalf("commit error = %v", err)
}
}
func TestCopilotInstallPreservesOrphanedRuntimeWithoutIdentity(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
if err := os.MkdirAll(fixture.agentDir, 0700); err != nil {
t.Fatalf("create agent runtime directory: %v", err)
}
if err := os.WriteFile(fixture.hookPath, []byte("orphaned\n"), 0700); err != nil {
t.Fatalf("write orphaned runtime: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "cannot be verified") {
t.Fatalf("install error = %v", err)
}
current, readErr := os.ReadFile(fixture.hookPath)
if readErr != nil || string(current) != "orphaned\n" {
t.Fatalf("orphaned runtime = %q, %v", current, readErr)
}
for _, path := range []string{
fixture.configPath, fixture.guardPath, fixture.runtimeConfig, fixture.privateKey,
} {
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected file at %s: %v", path, err)
}
}
}
func TestCopilotInstallRejectsLinkedHomeAndRuntimeDirectories(t *testing.T) {
for _, location := range []string{"home", "runtime"} {
t.Run(location, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
target := filepath.Join(filepath.Dir(fixture.homeDir), location+"-target")
if err := os.MkdirAll(target, 0700); err != nil {
t.Fatalf("create symlink target: %v", err)
}
linkPath := fixture.copilotHome
if location == "runtime" {
linkPath = fixture.agentDir
if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil {
t.Fatalf("create runtime root: %v", err)
}
}
if err := os.Symlink(target, linkPath); err != nil {
t.Skipf("create directory symlink: %v", err)
}
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), "physical directory") {
t.Fatalf("install error = %v", err)
}
})
}
}
func TestCopilotStatusRequiresExactRuntimeSourcesAndPrivateKey(t *testing.T) {
for _, name := range []string{"guard", "audit"} {
t.Run(name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
path := fixture.guardPath
if name == "audit" {
path = fixture.hookPath
}
if err := os.WriteFile(path, []byte("// tampered\n"), 0700); err != nil {
t.Fatalf("tamper %s runtime: %v", name, err)
}
status, err := fixture.plugin.Status()
if err != nil || status.Installed || status.HookScriptExists {
t.Fatalf("tampered %s status = %#v, %v", name, status, err)
}
})
}
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
installCopilotFixture(t, fixture)
if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil {
t.Fatalf("corrupt private key: %v", err)
}
if _, err := fixture.plugin.Status(); err == nil ||
!strings.Contains(err.Error(), "canonical 32-byte") {
t.Fatalf("invalid key status error = %v", err)
}
}
func TestCopilotValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
for _, testCase := range []struct {
name string
edit func(*copilotFixture)
want string
}{
{"agent name", func(f *copilotFixture) { f.config.AgentName = "codex" }, "requires agent name copilot"},
{"organization", func(f *copilotFixture) { f.config.OrgID = " " }, "organization ID is required"},
{"key ID", func(f *copilotFixture) { f.config.KID = "\t" }, "key ID is required"},
{"token", func(f *copilotFixture) { f.config.Token = " " }, "token must contain"},
{"private key", func(f *copilotFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"},
{"base URL", func(f *copilotFixture) {
f.config.BaseURL = "https://api.elydora.com/path?token=secret"
}, "query parameters"},
{"guard path", func(f *copilotFixture) {
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
}, "managed agent directory"},
{"audit path", func(f *copilotFixture) {
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
}, "managed agent directory"},
} {
t.Run(testCase.name, func(t *testing.T) {
fixture := prepareCopilotFixture(t, copilotFixtureOptions{})
testCase.edit(fixture)
err := fixture.plugin.Install(fixture.config)
if err == nil || !strings.Contains(err.Error(), testCase.want) {
t.Fatalf("install error = %v, want %q", err, testCase.want)
}
assertCopilotRuntimeAbsent(t, fixture)
})
}
}
+23 -1
View File
@@ -100,7 +100,12 @@ func prepareSourceChange(
}, nil
}
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")
@@ -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
}