fix(claude): implement official Go hook contract
This commit is contained in:
1 parent
f71db95502
commit
15d044cf63
17 files changed
+3006
-236
No files matched your search
@@ -0,0 +1,326 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type claudeRuntimeReference struct {
|
||||
agentID string
|
||||
scriptPath string
|
||||
}
|
||||
|
||||
func sameClaudePath(left, right string) bool {
|
||||
left = filepath.Clean(left)
|
||||
right = filepath.Clean(right)
|
||||
if absolute, err := filepath.Abs(left); err == nil {
|
||||
left = absolute
|
||||
}
|
||||
if absolute, err := filepath.Abs(right); err == nil {
|
||||
right = absolute
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func sameClaudeAgentID(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func isClaudeNodeExecutable(path string) bool {
|
||||
name := filepath.Base(path)
|
||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
||||
}
|
||||
|
||||
func sameClaudeFileName(left, right string) bool {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.EqualFold(left, right)
|
||||
}
|
||||
return left == right
|
||||
}
|
||||
|
||||
func claudeRuntimeReferenceForScript(
|
||||
scriptPath string,
|
||||
scriptName string,
|
||||
) (*claudeRuntimeReference, error) {
|
||||
if !filepath.IsAbs(scriptPath) ||
|
||||
!sameClaudeFileName(filepath.Base(scriptPath), scriptName) {
|
||||
return nil, nil
|
||||
}
|
||||
agentDirectory := filepath.Dir(scriptPath)
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return nil, nil
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
if agentID == "" || agentID == "." || agentID == ".." {
|
||||
return nil, nil
|
||||
}
|
||||
return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
||||
}
|
||||
|
||||
func claudeLegacyReference(
|
||||
command string,
|
||||
scriptName string,
|
||||
) (*claudeRuntimeReference, error) {
|
||||
if !strings.HasPrefix(command, "node ") || strings.ContainsAny(command, "\r\n") {
|
||||
return nil, nil
|
||||
}
|
||||
scriptPath := strings.TrimPrefix(command, "node ")
|
||||
if scriptPath == "" || strings.TrimSpace(scriptPath) != scriptPath {
|
||||
return nil, nil
|
||||
}
|
||||
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
||||
}
|
||||
|
||||
func managedClaudeReference(
|
||||
handler map[string]any,
|
||||
scriptName string,
|
||||
statusMessage string,
|
||||
includeLegacy bool,
|
||||
) (*claudeRuntimeReference, error) {
|
||||
if len(handler) == 5 && handler["type"] == "command" &&
|
||||
handler["timeout"] == claudeHookTimeout &&
|
||||
handler["statusMessage"] == statusMessage {
|
||||
nodePath, nodeOK := handler["command"].(string)
|
||||
args, argsOK := handler["args"].([]any)
|
||||
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) &&
|
||||
argsOK && len(args) == 1 {
|
||||
if scriptPath, ok := args[0].(string); ok {
|
||||
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if includeLegacy && len(handler) == 2 && handler["type"] == "command" {
|
||||
if command, ok := handler["command"].(string); ok {
|
||||
return claudeLegacyReference(command, scriptName)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func buildClaudeGroup(nodePath, scriptPath, statusMessage string) claudeGroup {
|
||||
return claudeGroup{
|
||||
object: map[string]any{},
|
||||
handlers: []map[string]any{{
|
||||
"type": "command", "command": nodePath, "args": []any{scriptPath},
|
||||
"timeout": claudeHookTimeout, "statusMessage": statusMessage,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func exactManagedClaudeGroup(group claudeGroup) bool {
|
||||
_, hasHooks := group.object["hooks"]
|
||||
return len(group.object) == 1 && hasHooks
|
||||
}
|
||||
|
||||
func removeManagedClaudeGroups(
|
||||
groups []claudeGroup,
|
||||
scriptName string,
|
||||
statusMessage string,
|
||||
agentID string,
|
||||
) ([]claudeGroup, bool, error) {
|
||||
result := make([]claudeGroup, 0, len(groups))
|
||||
removed := false
|
||||
for _, group := range groups {
|
||||
kept := make([]map[string]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
reference, err := managedClaudeReference(
|
||||
handler,
|
||||
scriptName,
|
||||
statusMessage,
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
owned := reference != nil &&
|
||||
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID))
|
||||
if owned {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
||||
object := cloneClaudeObject(group.object)
|
||||
object["hooks"] = group.object["hooks"]
|
||||
result = append(result, claudeGroup{object: object, handlers: kept})
|
||||
}
|
||||
}
|
||||
return result, removed, nil
|
||||
}
|
||||
|
||||
func removeManagedClaudeHooks(hooks claudeHooks, agentID string) (claudeHooks, error) {
|
||||
result := cloneClaudeHooks(hooks)
|
||||
for _, contract := range []struct{ event, script, status string }{
|
||||
{"PreToolUse", claudeGuardScript, claudeGuardStatusMessage},
|
||||
{"PostToolUse", claudeAuditScript, claudeAuditStatusMessage},
|
||||
{"PostToolUseFailure", claudeAuditScript, claudeAuditStatusMessage},
|
||||
} {
|
||||
groups, removed, err := removeManagedClaudeGroups(
|
||||
result[contract.event],
|
||||
contract.script,
|
||||
contract.status,
|
||||
agentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !removed {
|
||||
continue
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
delete(result, contract.event)
|
||||
} else {
|
||||
result[contract.event] = groups
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func managedClaudeEvent(event string) (string, string, bool) {
|
||||
switch event {
|
||||
case "PreToolUse":
|
||||
return claudeGuardScript, claudeGuardStatusMessage, true
|
||||
case "PostToolUse", "PostToolUseFailure":
|
||||
return claudeAuditScript, claudeAuditStatusMessage, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func entirelyManagedClaudeDocument(document *claudeDocument) bool {
|
||||
if !document.exists || len(document.root) != 1 || len(document.hooks) == 0 {
|
||||
return false
|
||||
}
|
||||
if _, hasHooks := document.root["hooks"]; !hasHooks {
|
||||
return false
|
||||
}
|
||||
handlerCount := 0
|
||||
for event, groups := range document.hooks {
|
||||
scriptName, statusMessage, managedEvent := managedClaudeEvent(event)
|
||||
if !managedEvent || len(groups) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, group := range groups {
|
||||
if !exactManagedClaudeGroup(group) || len(group.handlers) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, handler := range group.handlers {
|
||||
reference, err := managedClaudeReference(
|
||||
handler,
|
||||
scriptName,
|
||||
statusMessage,
|
||||
true,
|
||||
)
|
||||
if err != nil || reference == nil {
|
||||
return false
|
||||
}
|
||||
handlerCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
return handlerCount > 0
|
||||
}
|
||||
|
||||
func claudeReferenceKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
return agentID
|
||||
}
|
||||
|
||||
func claudeReferencesForEvent(
|
||||
groups []claudeGroup,
|
||||
scriptName string,
|
||||
statusMessage string,
|
||||
) (map[string][]claudeRuntimeReference, error) {
|
||||
result := map[string][]claudeRuntimeReference{}
|
||||
for _, group := range groups {
|
||||
if !exactManagedClaudeGroup(group) {
|
||||
continue
|
||||
}
|
||||
for _, handler := range group.handlers {
|
||||
reference, err := managedClaudeReference(
|
||||
handler,
|
||||
scriptName,
|
||||
statusMessage,
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reference != nil {
|
||||
key := claudeReferenceKey(reference.agentID)
|
||||
result[key] = append(result[key], *reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) {
|
||||
guards, err := claudeReferencesForEvent(
|
||||
hooks["PreToolUse"],
|
||||
claudeGuardScript,
|
||||
claudeGuardStatusMessage,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
successes, err := claudeReferencesForEvent(
|
||||
hooks["PostToolUse"],
|
||||
claudeAuditScript,
|
||||
claudeAuditStatusMessage,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
failures, err := claudeReferencesForEvent(
|
||||
hooks["PostToolUseFailure"],
|
||||
claudeAuditScript,
|
||||
claudeAuditStatusMessage,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
contracts := make([]claudeRuntimeContract, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
guard := guards[key]
|
||||
success := successes[key]
|
||||
failure := failures[key]
|
||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||
!sameClaudePath(success[0].scriptPath, failure[0].scriptPath) {
|
||||
continue
|
||||
}
|
||||
contracts = append(contracts, claudeRuntimeContract{
|
||||
agentID: guard[0].agentID, guardPath: guard[0].scriptPath,
|
||||
auditPath: success[0].scriptPath,
|
||||
})
|
||||
}
|
||||
return contracts, nil
|
||||
}
|
||||
|
||||
func requireClaudeAbsoluteNode(nodePath string) error {
|
||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
||||
return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user