package plugins import ( "bytes" "context" "encoding/json" "fmt" "math" "os/exec" "path/filepath" "runtime" "sort" "strings" "time" ) const ( droidAgentKey = "droid" droidGuardScript = "guard.js" droidAuditScript = "hook.js" droidHookTimeout = float64(10) droidOwnedFileMarker = "// Managed by Elydora" ) var droidToolEvents = [...]string{"PreToolUse", "PostToolUse"} type droidHookSettings map[string]any type droidManagedRemoval struct { event string groupIndex int handlerIndexes []int removeGroup bool } type droidRuntimeContract = managedRuntimeContract type droidRegexEntry struct { Label string `json:"label"` Pattern string `json:"pattern"` } func readDroidHookSettings(value any, label string) (droidHookSettings, error) { object, ok := value.(map[string]any) if !ok || object == nil { return nil, fmt.Errorf("%s must contain a JSON object", label) } hooks := make(droidHookSettings, len(object)) for event, item := range object { groups, ok := item.([]any) if !ok { return nil, fmt.Errorf(`%s field %q must be an array`, label, event) } for groupIndex, group := range groups { if err := validateDroidGroup(group, label, event, groupIndex); err != nil { return nil, err } } hooks[event] = item } return hooks, nil } func validateDroidGroup(value any, label, event string, groupIndex int) error { location := fmt.Sprintf(`%s field %q[%d]`, label, event, groupIndex) group, ok := value.(map[string]any) if !ok || group == nil { return fmt.Errorf("%s must be an object", location) } if matcher, exists := group["matcher"]; exists { if _, ok := matcher.(string); !ok { return fmt.Errorf("%s matcher must be a string", location) } } if expression, exists := group["commandRegex"]; exists { if _, ok := expression.(string); !ok { return fmt.Errorf("%s commandRegex must be a string", location) } } handlers, ok := group["hooks"].([]any) if !ok { return fmt.Errorf("%s must contain a hooks array", location) } for handlerIndex, handler := range handlers { if err := validateDroidHandler(handler, location, handlerIndex); err != nil { return err } } return nil } func validateDroidHandler(value any, groupLabel string, handlerIndex int) error { location := fmt.Sprintf("%s.hooks[%d]", groupLabel, handlerIndex) handler, ok := value.(map[string]any) if !ok || handler == nil { return fmt.Errorf("%s must be an object", location) } if handler["type"] != "command" { return fmt.Errorf(`%s type must be "command"`, location) } command, ok := handler["command"].(string) if !ok || strings.TrimSpace(command) == "" { return fmt.Errorf("%s command must be a non-empty string", location) } if timeout, exists := handler["timeout"]; exists { number, ok := timeout.(float64) if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number <= 0 { return fmt.Errorf("%s timeout must be a positive finite number", location) } } return nil } func validateDroidRegexes(settings ...droidHookSettings) error { entries := make([]droidRegexEntry, 0) for _, source := range settings { for event, value := range source { groups, _ := value.([]any) for index, groupValue := range groups { group := groupValue.(map[string]any) if matcher, ok := group["matcher"].(string); ok && matcher != "" && matcher != "*" { entries = append(entries, droidRegexEntry{ Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] matcher`, event, index), Pattern: matcher, }) } if expression, ok := group["commandRegex"].(string); ok { entries = append(entries, droidRegexEntry{ Label: fmt.Sprintf(`Factory Droid hooks field %q[%d] commandRegex`, event, index), Pattern: expression, }) } } } } if len(entries) == 0 { return nil } nodePath, err := resolveNodeRuntime() if err != nil { return fmt.Errorf("Node.js runtime is required to validate Factory Droid hook expressions: %w", err) } payload, err := json.Marshal(entries) if err != nil { return fmt.Errorf("encode Factory Droid regular expressions: %w", err) } validator := `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) { process.stderr.write(entry.label + ': ' + (error instanceof Error ? error.message : String(error))); process.exit(1); } }` ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // #nosec G204 -- nodePath is resolved by exec.LookPath and every argument is fixed. command := exec.CommandContext(ctx, nodePath, "--input-type=module", "--eval", validator) command.Stdin = bytes.NewReader(payload) output, runErr := command.CombinedOutput() if ctx.Err() != nil { return fmt.Errorf("Factory Droid regular expression validation timed out: %w", ctx.Err()) } if runErr != nil { message := strings.TrimSpace(string(output)) if message == "" { message = runErr.Error() } return fmt.Errorf("Factory Droid matcher must be a valid JavaScript regular expression: %s", message) } return nil } func buildDroidCommand(nodePath, scriptPath string) string { if runtime.GOOS == "windows" { return powerShellSource(nodePath, scriptPath) } return posixSource(nodePath, scriptPath) } func buildDroidGroup(nodePath, scriptPath string) map[string]any { return map[string]any{ "matcher": "*", "hooks": []any{map[string]any{ "type": "command", "command": buildDroidCommand(nodePath, scriptPath), "timeout": droidHookTimeout, }}, } } func parseDroidCommand(command string, includeLegacy ...bool) (string, string, bool) { if runtime.GOOS != "windows" { return parsePOSIXCommand(command) } executable, script, ok := parsePowerShellSource(command) if ok || len(includeLegacy) == 0 || !includeLegacy[0] { return executable, script, ok } return parseDroidLegacyWindowsCommand(command) } func parseDroidLegacyWindowsCommand(command string) (string, string, bool) { executable, next, ok := readDroidLegacyWindowsArgument(command, 0) if !ok || next >= len(command) || command[next] != ' ' { return "", "", false } script, end, ok := readDroidLegacyWindowsArgument(command, next+1) return executable, script, ok && end == len(command) && executable != "" && script != "" } func readDroidLegacyWindowsArgument(command string, start int) (string, int, bool) { if start >= len(command) || command[start] != '"' { return "", start, false } end := strings.IndexByte(command[start+1:], '"') if end < 0 { return "", start, false } end += start + 1 value := command[start+1 : end] return value, end + 1, value != "" && !strings.ContainsAny(value, "\r\n") } func managedDroidAgentID( handler map[string]any, scriptName, runtimeRoot string, includeLegacy ...bool, ) (string, bool) { if len(handler) != 3 || handler["type"] != "command" || handler["timeout"] != droidHookTimeout { return "", false } command, ok := handler["command"].(string) if !ok { return "", false } executable, scriptPath, ok := parseDroidCommand(command, includeLegacy...) if !ok || !filepath.IsAbs(executable) || !filepath.IsAbs(scriptPath) || !isNodeExecutable(executable) || !sameManagedName(filepath.Base(scriptPath), scriptName) { return "", false } agentDirectory := filepath.Dir(scriptPath) if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) { return "", false } agentID := filepath.Base(agentDirectory) return agentID, agentID != "" && agentID != "." && agentID != ".." } func managedDroidRemovals(settings droidHookSettings, agentID, runtimeRoot string) []droidManagedRemoval { removals := make([]droidManagedRemoval, 0) for _, contract := range []struct{ event, script string }{ {"PreToolUse", droidGuardScript}, {"PostToolUse", droidAuditScript}, } { groups, _ := settings[contract.event].([]any) for groupIndex, value := range groups { group := value.(map[string]any) handlers := group["hooks"].([]any) indexes := make([]int, 0) for handlerIndex, handlerValue := range handlers { managedID, managed := managedDroidAgentID( handlerValue.(map[string]any), contract.script, runtimeRoot, true, ) if managed && (agentID == "" || sameManagedAgentID(managedID, agentID)) { indexes = append(indexes, handlerIndex) } } if len(indexes) == 0 { continue } _, hasMatcher := group["matcher"] _, hasHooks := group["hooks"] exactGroup := len(group) == 2 && hasMatcher && hasHooks && group["matcher"] == "*" && len(indexes) == len(handlers) removals = append(removals, droidManagedRemoval{ contract.event, groupIndex, indexes, exactGroup, }) } } return removals } func droidRuntimeContracts(settings droidHookSettings, runtimeRoot string) []droidRuntimeContract { guards := managedDroidIDs(settings, "PreToolUse", droidGuardScript, runtimeRoot) audits := managedDroidIDs(settings, "PostToolUse", droidAuditScript, runtimeRoot) guardIDs := make([]string, 0, len(guards)) for agentID := range guards { guardIDs = append(guardIDs, agentID) } sort.Strings(guardIDs) contracts := make([]droidRuntimeContract, 0, len(guardIDs)) for _, guardID := range guardIDs { for auditID := range audits { if !sameManagedAgentID(guardID, auditID) { continue } root := filepath.Join(runtimeRoot, guardID) contracts = append(contracts, droidRuntimeContract{ guardID, filepath.Join(root, droidGuardScript), filepath.Join(root, droidAuditScript), }) break } } return contracts } func managedDroidIDs(settings droidHookSettings, event, script, runtimeRoot string) map[string]bool { ids := map[string]bool{} groups, _ := settings[event].([]any) for _, groupValue := range groups { group := groupValue.(map[string]any) handlers := group["hooks"].([]any) if len(group) != 2 || group["matcher"] != "*" || len(handlers) != 1 { continue } if agentID, ok := managedDroidAgentID(handlers[0].(map[string]any), script, runtimeRoot); ok { ids[agentID] = true } } return ids }