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

+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
}