306 lines
8.2 KiB
Go
306 lines
8.2 KiB
Go
package plugins
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
grokAgentKey = "grok"
|
|
grokGuardScript = "guard.js"
|
|
grokAuditScript = "hook.js"
|
|
grokHookTimeout = float64(10)
|
|
grokConfigFile = "elydora-audit.json"
|
|
grokPOSIXApostrophe = `'"'"'`
|
|
)
|
|
|
|
type grokGroup struct {
|
|
object map[string]any
|
|
handlers []map[string]any
|
|
}
|
|
|
|
type grokHooks map[string][]grokGroup
|
|
|
|
type grokDocument struct {
|
|
exists bool
|
|
configPath string
|
|
root map[string]any
|
|
hooks grokHooks
|
|
}
|
|
|
|
type grokRuntimeContract struct {
|
|
agentID string
|
|
guardPath string
|
|
auditPath string
|
|
}
|
|
|
|
func cloneGrokObject(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 validateGrokHandler(value any, event string, groupIndex, handlerIndex int) (map[string]any, error) {
|
|
label := fmt.Sprintf("Grok hooks config handler hooks.%s[%d].hooks[%d]", event, groupIndex, handlerIndex)
|
|
handler, ok := value.(map[string]any)
|
|
if !ok || handler == nil {
|
|
return nil, fmt.Errorf("%s must be an object", label)
|
|
}
|
|
handlerType, ok := handler["type"].(string)
|
|
if !ok || (handlerType != "command" && handlerType != "http") {
|
|
return nil, fmt.Errorf(`%s has unsupported type %q`, label, fmt.Sprint(handler["type"]))
|
|
}
|
|
if handlerType == "command" {
|
|
command, ok := handler["command"].(string)
|
|
if !ok || command == "" {
|
|
return nil, fmt.Errorf("%s requires a non-empty command", label)
|
|
}
|
|
}
|
|
if handlerType == "http" {
|
|
url, ok := handler["url"].(string)
|
|
if !ok || url == "" {
|
|
return nil, fmt.Errorf("%s requires a non-empty url", label)
|
|
}
|
|
}
|
|
if timeoutValue, exists := handler["timeout"]; exists {
|
|
timeout, ok := timeoutValue.(float64)
|
|
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 {
|
|
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
|
}
|
|
}
|
|
return cloneGrokObject(handler), nil
|
|
}
|
|
|
|
func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) {
|
|
label := fmt.Sprintf("Grok hooks config group hooks.%s[%d]", event, groupIndex)
|
|
object, ok := value.(map[string]any)
|
|
if !ok || object == nil {
|
|
return grokGroup{}, fmt.Errorf("%s must be an object", label)
|
|
}
|
|
if matcher, exists := object["matcher"]; exists {
|
|
if _, ok := matcher.(string); !ok {
|
|
return grokGroup{}, fmt.Errorf("%s matcher must be a string", label)
|
|
}
|
|
}
|
|
values, ok := object["hooks"].([]any)
|
|
if !ok {
|
|
return grokGroup{}, fmt.Errorf("%s must contain a hooks array", label)
|
|
}
|
|
handlers := make([]map[string]any, 0, len(values))
|
|
for handlerIndex, value := range values {
|
|
handler, err := validateGrokHandler(value, event, groupIndex, handlerIndex)
|
|
if err != nil {
|
|
return grokGroup{}, err
|
|
}
|
|
handlers = append(handlers, handler)
|
|
}
|
|
return grokGroup{object: cloneGrokObject(object), handlers: handlers}, nil
|
|
}
|
|
|
|
func readGrokHooks(root map[string]any) (grokHooks, error) {
|
|
value, exists := root["hooks"]
|
|
if !exists {
|
|
return grokHooks{}, nil
|
|
}
|
|
object, ok := value.(map[string]any)
|
|
if !ok || object == nil {
|
|
return nil, fmt.Errorf(`Grok hooks config field "hooks" must be an object`)
|
|
}
|
|
hooks := make(grokHooks, len(object))
|
|
for event, groupValue := range object {
|
|
values, ok := groupValue.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf(`Grok hooks config field "hooks.%s" must be an array`, event)
|
|
}
|
|
groups := make([]grokGroup, 0, len(values))
|
|
for groupIndex, value := range values {
|
|
group, err := validateGrokGroup(value, event, groupIndex)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
groups = append(groups, group)
|
|
}
|
|
hooks[event] = groups
|
|
}
|
|
return hooks, nil
|
|
}
|
|
|
|
func renderGrokHooks(hooks grokHooks) map[string]any {
|
|
result := make(map[string]any, len(hooks))
|
|
for event, groups := range hooks {
|
|
values := make([]any, 0, len(groups))
|
|
for _, group := range groups {
|
|
object := cloneGrokObject(group.object)
|
|
handlers := make([]any, 0, len(group.handlers))
|
|
for _, handler := range group.handlers {
|
|
handlers = append(handlers, handler)
|
|
}
|
|
object["hooks"] = handlers
|
|
values = append(values, object)
|
|
}
|
|
result[event] = values
|
|
}
|
|
return result
|
|
}
|
|
|
|
func readGrokWindowsArgument(command string, start int) (string, int, bool) {
|
|
if start >= len(command) || command[start] != '"' {
|
|
return "", start, false
|
|
}
|
|
var value strings.Builder
|
|
for index := start + 1; index < len(command); index++ {
|
|
if command[index] == '"' {
|
|
return value.String(), index + 1, true
|
|
}
|
|
value.WriteByte(command[index])
|
|
}
|
|
return "", start, false
|
|
}
|
|
|
|
func readGrokPOSIXArgument(command string, start int) (string, int, bool) {
|
|
if start >= len(command) || command[start] != '\'' {
|
|
return "", start, false
|
|
}
|
|
var value strings.Builder
|
|
for index := start + 1; index < len(command); {
|
|
if strings.HasPrefix(command[index:], grokPOSIXApostrophe) {
|
|
value.WriteByte('\'')
|
|
index += len(grokPOSIXApostrophe)
|
|
continue
|
|
}
|
|
if command[index] == '\'' {
|
|
return value.String(), index + 1, true
|
|
}
|
|
value.WriteByte(command[index])
|
|
index++
|
|
}
|
|
return "", start, false
|
|
}
|
|
|
|
func parseGrokCommand(command string) (string, string, bool) {
|
|
reader := readGrokPOSIXArgument
|
|
if runtime.GOOS == "windows" {
|
|
reader = readGrokWindowsArgument
|
|
}
|
|
executable, next, ok := reader(command, 0)
|
|
if !ok || next >= len(command) || command[next] != ' ' {
|
|
return "", "", false
|
|
}
|
|
script, end, ok := reader(command, next+1)
|
|
if !ok || end != len(command) || executable == "" || script == "" {
|
|
return "", "", false
|
|
}
|
|
return executable, script, true
|
|
}
|
|
|
|
func normalizeGrokPath(value string) string {
|
|
absolute, err := filepath.Abs(value)
|
|
if err == nil {
|
|
value = absolute
|
|
}
|
|
value = filepath.ToSlash(filepath.Clean(value))
|
|
if runtime.GOOS == "windows" {
|
|
return strings.ToLower(value)
|
|
}
|
|
return value
|
|
}
|
|
|
|
func sameGrokPath(left, right string) bool {
|
|
return normalizeGrokPath(left) == normalizeGrokPath(right)
|
|
}
|
|
|
|
func sameGrokAgentID(left, right string) bool {
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(left, right)
|
|
}
|
|
return left == right
|
|
}
|
|
|
|
func sameGrokFileName(left, right string) bool {
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(left, right)
|
|
}
|
|
return left == right
|
|
}
|
|
|
|
func managedGrokAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) {
|
|
if handler["type"] != "command" || handler["timeout"] != grokHookTimeout {
|
|
return "", false
|
|
}
|
|
command, ok := handler["command"].(string)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
_, scriptPath, ok := parseGrokCommand(command)
|
|
if !ok || !sameGrokFileName(filepath.Base(scriptPath), scriptName) {
|
|
return "", false
|
|
}
|
|
agentDirectory := filepath.Dir(scriptPath)
|
|
if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
return "", false
|
|
}
|
|
agentID := filepath.Base(agentDirectory)
|
|
return agentID, agentID != "" && agentID != "." && agentID != ".."
|
|
}
|
|
|
|
func removeManagedGrokGroups(
|
|
groups []grokGroup,
|
|
scriptName, agentID, runtimeRoot string,
|
|
) ([]grokGroup, bool) {
|
|
result := make([]grokGroup, 0, len(groups))
|
|
changed := false
|
|
for _, group := range groups {
|
|
if _, hasMatcher := group.object["matcher"]; hasMatcher {
|
|
result = append(result, group)
|
|
continue
|
|
}
|
|
handlers := make([]map[string]any, 0, len(group.handlers))
|
|
groupChanged := false
|
|
for _, handler := range group.handlers {
|
|
managedID, managed := managedGrokAgentID(handler, scriptName, runtimeRoot)
|
|
remove := managed && (agentID == "" || sameGrokAgentID(managedID, agentID))
|
|
if remove {
|
|
changed = true
|
|
groupChanged = true
|
|
continue
|
|
}
|
|
handlers = append(handlers, handler)
|
|
}
|
|
if len(handlers) > 0 || !groupChanged {
|
|
result = append(result, grokGroup{object: group.object, handlers: handlers})
|
|
}
|
|
}
|
|
return result, changed
|
|
}
|
|
|
|
func removeManagedGrokHooks(hooks grokHooks, agentID, runtimeRoot string) (grokHooks, bool) {
|
|
result := make(grokHooks, len(hooks))
|
|
for event, groups := range hooks {
|
|
result[event] = groups
|
|
}
|
|
changed := false
|
|
for _, contract := range []struct{ event, script string }{
|
|
{"PreToolUse", grokGuardScript},
|
|
{"PostToolUse", grokAuditScript},
|
|
} {
|
|
groups, eventChanged := removeManagedGrokGroups(
|
|
result[contract.event], contract.script, agentID, runtimeRoot,
|
|
)
|
|
if !eventChanged {
|
|
continue
|
|
}
|
|
changed = true
|
|
if len(groups) == 0 {
|
|
delete(result, contract.event)
|
|
} else {
|
|
result[contract.event] = groups
|
|
}
|
|
}
|
|
return result, changed
|
|
}
|