From a0fe65795916d5f535986fc14cb510672c3a37b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 07:50:22 -0400 Subject: [PATCH] feat: add native Grok hook integration --- AGENTS.md | 1 + README.md | 3 + cmd/elydora/plugins/grok.go | 113 ++++++ cmd/elydora/plugins/grok_contract.go | 305 +++++++++++++++ cmd/elydora/plugins/grok_io.go | 181 +++++++++ cmd/elydora/plugins/grok_test.go | 368 ++++++++++++++++++ cmd/elydora/plugins/grok_test_helpers_test.go | 166 ++++++++ cmd/elydora/plugins/registry.go | 3 + 8 files changed, 1140 insertions(+) create mode 100644 cmd/elydora/plugins/grok.go create mode 100644 cmd/elydora/plugins/grok_contract.go create mode 100644 cmd/elydora/plugins/grok_io.go create mode 100644 cmd/elydora/plugins/grok_test.go create mode 100644 cmd/elydora/plugins/grok_test_helpers_test.go diff --git a/AGENTS.md b/AGENTS.md index f811026..d78bf00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. - Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker. - Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. +- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. ## Code Quality diff --git a/README.md b/README.md index b65f4d1..39d86af 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present. +Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. + ### Commands | Command | Description | @@ -98,6 +100,7 @@ Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_ | Copilot CLI | `copilot` | | Cursor | `cursor` | | Gemini CLI | `gemini` | +| Grok Build | `grok` | | Kiro CLI | `kirocli` | | Kiro IDE | `kiroide` | | Kimi Code | `kimi` | diff --git a/cmd/elydora/plugins/grok.go b/cmd/elydora/plugins/grok.go new file mode 100644 index 0000000..e517934 --- /dev/null +++ b/cmd/elydora/plugins/grok.go @@ -0,0 +1,113 @@ +package plugins + +import "fmt" + +// GrokPlugin manages Grok Build native global user hooks. +type GrokPlugin struct{} + +func (p *GrokPlugin) Install(config InstallConfig) error { + if config.AgentID == "" { + return fmt.Errorf("agent ID is required") + } + document, err := readGrokConfig() + if err != nil { + return err + } + if config.GuardScriptPath == "" { + return fmt.Errorf("guard script path is required") + } + guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") + if err != nil { + return err + } + if !guardExists { + return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) + } + hookPath, err := hookScriptPath(config.AgentID) + if err != nil { + return err + } + if config.HookScript != "" { + hookPath = config.HookScript + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return err + } + runtimeRoot, err := grokRuntimeRoot() + if err != nil { + return err + } + hooks, _ := removeManagedGrokHooks(document.hooks, "", runtimeRoot) + hooks["PreToolUse"] = append(hooks["PreToolUse"], buildGrokGroup( + buildGrokHandler(buildGrokCommand(nodePath, config.GuardScriptPath)), + )) + hooks["PostToolUse"] = append(hooks["PostToolUse"], buildGrokGroup( + buildGrokHandler(buildGrokCommand(nodePath, hookPath)), + )) + next := cloneGrokObject(document.root) + next["hooks"] = renderGrokHooks(hooks) + + runtimeConfig := config + runtimeConfig.AgentName = grokAgentKey + if err := GenerateHookScript(hookPath, runtimeConfig); err != nil { + return fmt.Errorf("generate hook script: %w", err) + } + if err := writeHookJSONObjectAtomic(document.configPath, next); err != nil { + return fmt.Errorf("write Grok hooks config: %w", err) + } + fmt.Println("Grok Build: global PreToolUse and PostToolUse hooks installed.") + return nil +} + +func (p *GrokPlugin) Uninstall(agentID string) error { + document, err := readGrokConfig() + if err != nil || !document.exists { + return err + } + runtimeRoot, err := grokRuntimeRoot() + if err != nil { + return err + } + hooks, changed := removeManagedGrokHooks(document.hooks, agentID, runtimeRoot) + if !changed { + return nil + } + next := cloneGrokObject(document.root) + if len(hooks) == 0 { + delete(next, "hooks") + } else { + next["hooks"] = renderGrokHooks(hooks) + } + if len(next) == 0 { + return removeHookFile(document.configPath, "Grok hooks config") + } + return writeHookJSONObjectAtomic(document.configPath, next) +} + +func (p *GrokPlugin) Status() (PluginStatus, error) { + document, err := readGrokConfig() + status := PluginStatus{ + AgentName: grokAgentKey, + DisplayName: "Grok Build", + ConfigPath: document.configPath, + } + if err != nil { + return status, err + } + runtimeRoot, err := grokRuntimeRoot() + if err != nil { + return status, err + } + contracts := grokRuntimeContracts(document.hooks, runtimeRoot) + if len(contracts) == 0 { + return status, nil + } + status.HookConfigured = true + status.HookScriptExists, err = grokRuntimeScriptsExist(contracts, runtimeRoot) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists + return status, nil +} diff --git a/cmd/elydora/plugins/grok_contract.go b/cmd/elydora/plugins/grok_contract.go new file mode 100644 index 0000000..f479aa0 --- /dev/null +++ b/cmd/elydora/plugins/grok_contract.go @@ -0,0 +1,305 @@ +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 +} diff --git a/cmd/elydora/plugins/grok_io.go b/cmd/elydora/plugins/grok_io.go new file mode 100644 index 0000000..c7d6159 --- /dev/null +++ b/cmd/elydora/plugins/grok_io.go @@ -0,0 +1,181 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" +) + +func grokHomeDirectory() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return home, nil +} + +func grokRuntimeRoot() (string, error) { + home, err := grokHomeDirectory() + if err != nil { + return "", err + } + return filepath.Join(home, ".elydora"), nil +} + +func grokConfigPath() (string, error) { + home, err := grokHomeDirectory() + if err != nil { + return "", err + } + grokHome := os.Getenv("GROK_HOME") + if grokHome == "" { + grokHome = filepath.Join(home, ".grok") + } + // #nosec G703 -- GROK_HOME is an explicit local-user configuration path. + return filepath.Join(grokHome, "hooks", grokConfigFile), nil +} + +func readGrokConfig() (grokDocument, error) { + configPath, err := grokConfigPath() + if err != nil { + return grokDocument{}, err + } + root, exists, err := readHookJSONObject(configPath, "Grok hooks config") + if err != nil { + return grokDocument{}, err + } + hooks, err := readGrokHooks(root) + if err != nil { + return grokDocument{}, err + } + return grokDocument{exists: exists, configPath: configPath, root: root, hooks: hooks}, nil +} + +func quoteGrokWindowsArgument(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` +} + +func buildGrokCommand(runtimePath, scriptPath string) string { + if runtime.GOOS == "windows" { + return quoteGrokWindowsArgument(runtimePath) + " " + quoteGrokWindowsArgument(scriptPath) + } + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) +} + +func buildGrokHandler(command string) map[string]any { + return map[string]any{ + "type": "command", + "command": command, + "timeout": grokHookTimeout, + } +} + +func buildGrokGroup(handler map[string]any) grokGroup { + return grokGroup{object: map[string]any{}, handlers: []map[string]any{handler}} +} + +func managedGrokIDs(groups []grokGroup, scriptName, runtimeRoot string) map[string]string { + result := map[string]string{} + for _, group := range groups { + if _, hasMatcher := group.object["matcher"]; hasMatcher { + continue + } + for _, handler := range group.handlers { + agentID, managed := managedGrokAgentID(handler, scriptName, runtimeRoot) + if managed { + key := agentID + if runtime.GOOS == "windows" { + key = strings.ToLower(key) + } + result[key] = agentID + } + } + } + return result +} + +func grokRuntimeContracts(hooks grokHooks, runtimeRoot string) []grokRuntimeContract { + guards := managedGrokIDs(hooks["PreToolUse"], grokGuardScript, runtimeRoot) + audits := managedGrokIDs(hooks["PostToolUse"], grokAuditScript, runtimeRoot) + keys := make([]string, 0, len(guards)) + for key := range guards { + if _, exists := audits[key]; exists { + keys = append(keys, key) + } + } + sort.Strings(keys) + contracts := make([]grokRuntimeContract, 0, len(keys)) + for _, key := range keys { + agentID := guards[key] + contracts = append(contracts, grokRuntimeContract{ + agentID: agentID, + guardPath: filepath.Join(runtimeRoot, agentID, grokGuardScript), + auditPath: filepath.Join(runtimeRoot, agentID, grokAuditScript), + }) + } + return contracts +} + +func grokRuntimeScriptsExist(contracts []grokRuntimeContract, runtimeRoot string) (bool, error) { + entries, err := os.ReadDir(runtimeRoot) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("read Elydora runtime directory at %s: %w", runtimeRoot, err) + } + for _, contract := range contracts { + var entryName string + for _, entry := range entries { + if entry.IsDir() && sameGrokAgentID(entry.Name(), contract.agentID) { + entryName = entry.Name() + break + } + } + if entryName == "" { + continue + } + agentDirectory := filepath.Join(runtimeRoot, entryName) + guardPath := filepath.Join(agentDirectory, grokGuardScript) + auditPath := filepath.Join(agentDirectory, grokAuditScript) + if !sameGrokPath(guardPath, contract.guardPath) || + !sameGrokPath(auditPath, contract.auditPath) { + continue + } + configPath := filepath.Join(agentDirectory, "config.json") + config, exists, err := readHookJSONObject(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !exists { + continue + } + agentName, ok := config["agent_name"].(string) + if !ok { + return false, fmt.Errorf( + `Elydora runtime config at %s field "agent_name" must be a string`, + configPath, + ) + } + if agentName != grokAgentKey { + continue + } + guardExists, err := regularFileExists( + guardPath, "Elydora guard runtime", + ) + if err != nil { + return false, err + } + auditExists, err := regularFileExists( + auditPath, "Elydora audit runtime", + ) + if err != nil { + return false, err + } + return guardExists && auditExists, nil + } + return false, nil +} diff --git a/cmd/elydora/plugins/grok_test.go b/cmd/elydora/plugins/grok_test.go new file mode 100644 index 0000000..9389073 --- /dev/null +++ b/cmd/elydora/plugins/grok_test.go @@ -0,0 +1,368 @@ +package plugins + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestGrokRegistryAndFactory(t *testing.T) { + entry := SupportedAgents[grokAgentKey] + if entry.Name != "Grok Build" || entry.ConfigDir != "~/.grok/hooks" || entry.ConfigFile != grokConfigFile { + t.Fatalf("Grok registry entry = %#v", entry) + } + if _, ok := NewPlugin(grokAgentKey).(*GrokPlugin); !ok { + t.Fatalf("Grok plugin factory returned %T", NewPlugin(grokAgentKey)) + } +} + +func TestGrokGeneratedArgumentParsers(t *testing.T) { + posixCommand := quotePOSIXArgument("/tmp/node's runtime") + " " + + quotePOSIXArgument("/tmp/home's agent/guard.js") + executable, next, ok := readGrokPOSIXArgument(posixCommand, 0) + if !ok || executable != "/tmp/node's runtime" || posixCommand[next] != ' ' { + t.Fatalf("parse POSIX executable = %q, %d, %v", executable, next, ok) + } + script, end, ok := readGrokPOSIXArgument(posixCommand, next+1) + if !ok || script != "/tmp/home's agent/guard.js" || end != len(posixCommand) { + t.Fatalf("parse POSIX script = %q, %d, %v", script, end, ok) + } + + windowsCommand := quoteGrokWindowsArgument(`C:\Program Files\node.exe`) + " " + + quoteGrokWindowsArgument(`C:\home with spaces\.elydora\agent-1\guard.js`) + executable, next, ok = readGrokWindowsArgument(windowsCommand, 0) + if !ok || executable != `C:\Program Files\node.exe` || windowsCommand[next] != ' ' { + t.Fatalf("parse Windows executable = %q, %d, %v", executable, next, ok) + } + script, end, ok = readGrokWindowsArgument(windowsCommand, next+1) + if !ok || script != `C:\home with spaces\.elydora\agent-1\guard.js` || end != len(windowsCommand) { + t.Fatalf("parse Windows script = %q, %d, %v", script, end, ok) + } +} + +func TestGrokInstallPreservesConfigAndIsIdempotent(t *testing.T) { + existing := `{ + "schemaVersion": 1, + "hooks": { + "SessionStart": [{"matcher":"startup","hooks":[{"type":"http","url":"https://example.test/hook","timeout":5,"headers":{"x":"keep"}}],"label":"keep"}], + "PreToolUse": [{"matcher":"Bash","hooks":[{"type":"command","command":"existing-command","timeout":5}]}] + } +}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat Grok install: %v", err) + } + settings := readGrokTestObject(t, fixture.configPath) + if settings["schemaVersion"] != float64(1) { + t.Fatalf("schema version changed: %#v", settings["schemaVersion"]) + } + hooks := requireObject(t, settings["hooks"]) + if len(requireArray(t, hooks["SessionStart"])) != 1 || + len(requireArray(t, hooks["PreToolUse"])) != 2 || + len(requireArray(t, hooks["PostToolUse"])) != 1 { + t.Fatalf("unexpected hooks after repeat install: %#v", hooks) + } + guard := grokTestManagedHandler(t, settings, "PreToolUse", grokGuardScript) + audit := grokTestManagedHandler(t, settings, "PostToolUse", grokAuditScript) + for _, handler := range []map[string]any{guard, audit} { + wantKeys := map[string]struct{}{"type": {}, "command": {}, "timeout": {}} + if len(handler) != len(wantKeys) { + t.Fatalf("handler has extra fields: %#v", handler) + } + for key := range handler { + if _, exists := wantKeys[key]; !exists { + t.Fatalf("handler has unexpected field %q", key) + } + } + if handler["type"] != "command" || handler["timeout"] != grokHookTimeout { + t.Fatalf("unexpected handler: %#v", handler) + } + } + runtimeConfig := readGrokTestObject(t, fixture.runtimeConfig) + if runtimeConfig["agent_name"] != grokAgentKey { + t.Fatalf("runtime agent name = %#v", runtimeConfig["agent_name"]) + } + for _, path := range []string{ + filepath.Join(fixture.homeDir, ".claude", "settings.json"), + filepath.Join(fixture.homeDir, ".cursor", "hooks.json"), + filepath.Join(fixture.homeDir, ".grok", "hooks", grokConfigFile), + } { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("compatibility or default config was written at %s: %v", path, err) + } + } +} + +func TestGrokEmptyHomeOverrideUsesDefault(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{useDefaultHome: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("default-home status = %#v, %v", status, err) + } +} + +func TestGrokCommandsBlockAndForwardPayloadByteForByte(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + captureJSON, err := json.Marshal(capturePath) + if err != nil { + t.Fatalf("marshal capture path: %v", err) + } + captureScript := "const fs = require('node:fs'); const chunks = []; " + + "process.stdin.on('data', chunk => chunks.push(chunk)); " + + "process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", Buffer.concat(chunks)));\n" + if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil { + t.Fatalf("write capture hook: %v", err) + } + settings := readGrokTestObject(t, fixture.configPath) + prePayload := `{"hookEventName":"PreToolUse","sessionId":"session-1","cwd":"workspace","workspaceRoot":"workspace","toolName":"Bash","toolInput":{"command":"echo test"}}` + guard := grokTestManagedHandler(t, settings, "PreToolUse", grokGuardScript) + exitCode, stderr := runGrokCommand(t, guard["command"].(string), fixture.homeDir, prePayload) + if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") { + t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr) + } + postPayload := `{"hookEventName":"PostToolUse","sessionId":"session-1","cwd":"workspace","workspaceRoot":"workspace","toolName":"Bash","toolInput":{"command":"echo test"},"toolResult":{"output":"test"}}` + audit := grokTestManagedHandler(t, settings, "PostToolUse", grokAuditScript) + exitCode, stderr = runGrokCommand(t, audit["command"].(string), fixture.homeDir, postPayload) + if exitCode != 0 { + t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr) + } + captured, err := os.ReadFile(capturePath) + if err != nil || string(captured) != postPayload { + t.Fatalf("captured payload = %q, %v", captured, err) + } +} + +func TestGrokStatusRequiresPairAndBothRuntimes(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists { + t.Fatalf("installed status = %#v, %v", status, err) + } + settings := readGrokTestObject(t, fixture.configPath) + hooks := requireObject(t, settings["hooks"]) + delete(hooks, "PostToolUse") + writeGrokTestObject(t, fixture.configPath, settings) + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("incomplete status = %#v, %v", status, err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("restore Grok hooks: %v", err) + } + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { + t.Fatalf("missing-runtime status = %#v, %v", status, err) + } +} + +func TestGrokUninstallPreservesMixedHandlersAndExactAgent(t *testing.T) { + existing := `{"owner":"user","hooks":{"Notification":[]}}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + settings := readGrokTestObject(t, fixture.configPath) + hooks := requireObject(t, settings["hooks"]) + preGroups := requireArray(t, hooks["PreToolUse"]) + managedGroup := requireObject(t, preGroups[len(preGroups)-1]) + managedGroup["hooks"] = append(requireArray(t, managedGroup["hooks"]), map[string]any{ + "type": "command", "command": "user-command", "timeout": grokHookTimeout, + }) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + preGroups = append(preGroups, + map[string]any{"hooks": []any{buildGrokHandler(buildGrokCommand(nodePath, filepath.Join(fixture.agentDir, "guard.js.backup")))}}, + map[string]any{"hooks": []any{buildGrokHandler(buildGrokCommand(nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", grokGuardScript)))}}, + ) + hooks["PreToolUse"] = preGroups + writeGrokTestObject(t, fixture.configPath, settings) + uninstallID := grokTestAgentID + if runtime.GOOS == "windows" { + uninstallID = "AGENT-1" + } + if err := fixture.plugin.Uninstall(uninstallID); err != nil { + t.Fatalf("uninstall Grok hooks: %v", err) + } + remaining := readGrokTestObject(t, fixture.configPath) + remainingHooks := requireObject(t, remaining["hooks"]) + if remaining["owner"] != "user" || len(requireArray(t, remainingHooks["PreToolUse"])) != 3 { + t.Fatalf("user config changed: %#v", remaining) + } + if !reflect.DeepEqual(requireArray(t, remainingHooks["Notification"]), []any{}) { + t.Fatalf("empty notification changed: %#v", remainingHooks["Notification"]) + } + raw, _ := os.ReadFile(fixture.configPath) + if !strings.Contains(string(raw), "guard.js.backup") || !strings.Contains(string(raw), "agent-10") { + t.Fatalf("similar handlers were removed: %s", raw) + } + if _, exists := remainingHooks["PostToolUse"]; exists { + t.Fatalf("managed PostToolUse remains: %#v", remainingHooks["PostToolUse"]) + } +} + +func TestGrokInstallReplacesStaleHandlersForEveryAgent(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + settings := readGrokTestObject(t, fixture.configPath) + hooks := requireObject(t, settings["hooks"]) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js: %v", err) + } + for _, contract := range []struct{ event, script string }{ + {"PreToolUse", grokGuardScript}, {"PostToolUse", grokAuditScript}, + } { + hooks[contract.event] = append(requireArray(t, hooks[contract.event]), map[string]any{ + "hooks": []any{buildGrokHandler(buildGrokCommand( + nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-old", contract.script), + ))}, + }) + } + writeGrokTestObject(t, fixture.configPath, settings) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("replace stale Grok hooks: %v", err) + } + raw, _ := os.ReadFile(fixture.configPath) + current := readGrokTestObject(t, fixture.configPath) + currentHooks := requireObject(t, current["hooks"]) + if strings.Contains(string(raw), "agent-old") || + len(requireArray(t, currentHooks["PreToolUse"])) != 1 || + len(requireArray(t, currentHooks["PostToolUse"])) != 1 { + t.Fatalf("stale hooks remain: %s", raw) + } +} + +func TestGrokUninstallPreservesUntouchedEmptyGroup(t *testing.T) { + existing := `{"owner":"user"}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + settings := readGrokTestObject(t, fixture.configPath) + hooks := requireObject(t, settings["hooks"]) + hooks["PreToolUse"] = []any{map[string]any{"hooks": []any{}}} + writeGrokTestObject(t, fixture.configPath, settings) + if err := fixture.plugin.Uninstall(grokTestAgentID); err != nil { + t.Fatalf("uninstall Grok hook: %v", err) + } + remaining := requireObject(t, readGrokTestObject(t, fixture.configPath)["hooks"]) + preGroups := requireArray(t, remaining["PreToolUse"]) + if len(preGroups) != 1 || len(requireArray(t, requireObject(t, preGroups[0])["hooks"])) != 0 { + t.Fatalf("untouched empty group changed: %#v", remaining) + } + if _, exists := remaining["PostToolUse"]; exists { + t.Fatalf("managed event remains: %#v", remaining) + } +} + +func TestGrokUninstallRemovesOwnedConfig(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + if err := fixture.plugin.Uninstall(grokTestAgentID); err != nil { + t.Fatalf("uninstall Grok hooks: %v", err) + } + if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) { + t.Fatalf("owned Grok config remains: %v", err) + } +} + +func TestGrokMalformedConfigAndShapesPreventRuntimeWrites(t *testing.T) { + for _, testCase := range []struct{ name, raw, want string }{ + {"malformed", "{ malformed", "parse Grok hooks config"}, + {"null-root", "null", "must contain a JSON object"}, + {"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`}, + {"null-event", `{"hooks":{"PreToolUse":null}}`, `field "hooks.PreToolUse" must be an array`}, + {"null-group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"}, + {"null-matcher", `{"hooks":{"PreToolUse":[{"matcher":null,"hooks":[]}]}}`, "matcher must be a string"}, + {"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "must contain a hooks array"}, + {"empty-command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "requires a non-empty command"}, + {"object-type", `{"hooks":{"PreToolUse":[{"hooks":[{"type":{},"command":"x"}]}]}}`, "unsupported type"}, + {"empty-url", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","url":""}]}]}}`, "requires a non-empty url"}, + {"zero-timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite number"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &testCase.raw}) + 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) + } + raw, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(raw) != testCase.raw { + t.Fatalf("original config changed: %q, %v", raw, readErr) + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("runtime write occurred at %s: %v", path, err) + } + } + }) + } +} + +func TestGrokMissingGuardPreventsWrites(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{withoutGuard: true}) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { + t.Fatalf("install error = %v", err) + } + for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("write occurred at %s: %v", path, err) + } + } +} + +func TestGrokStatusSurfacesMalformedRuntimeMetadata(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") { + t.Fatalf("status error = %v", err) + } +} + +func TestGrokAtomicWritesLeaveNoTemporaryFiles(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + entries, err := os.ReadDir(filepath.Dir(fixture.configPath)) + if err != nil { + t.Fatalf("read Grok config directory: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("temporary file remains: %s", entry.Name()) + } + } +} diff --git a/cmd/elydora/plugins/grok_test_helpers_test.go b/cmd/elydora/plugins/grok_test_helpers_test.go new file mode 100644 index 0000000..f0348f7 --- /dev/null +++ b/cmd/elydora/plugins/grok_test_helpers_test.go @@ -0,0 +1,166 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +const grokTestAgentID = "agent-1" + +type grokFixtureOptions struct { + existingRaw *string + useDefaultHome bool + withoutGuard bool +} + +type grokFixture struct { + plugin *GrokPlugin + config InstallConfig + homeDir string + grokHome string + agentDir string + configPath string + guardPath string + hookPath string + runtimeConfig string + privateKey string +} + +func grokString(value string) *string { + return &value +} + +func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture { + t.Helper() + homeDir := filepath.Join(t.TempDir(), "home with spaces") + grokHome := filepath.Join(homeDir, "custom grok") + if options.useDefaultHome { + grokHome = filepath.Join(homeDir, ".grok") + } + agentDir := filepath.Join(homeDir, ".elydora", grokTestAgentID) + guardPath := filepath.Join(agentDir, grokGuardScript) + configPath := filepath.Join(grokHome, "hooks", grokConfigFile) + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatalf("create agent directory: %v", err) + } + if !options.withoutGuard { + source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n" + if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil { + t.Fatalf("write guard runtime: %v", err) + } + } + if options.existingRaw != nil { + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatalf("create Grok config directory: %v", err) + } + if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil { + t.Fatalf("write Grok config: %v", err) + } + } + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + if options.useDefaultHome { + t.Setenv("GROK_HOME", "") + } else { + t.Setenv("GROK_HOME", grokHome) + } + return &grokFixture{ + plugin: &GrokPlugin{}, + homeDir: homeDir, + grokHome: grokHome, + agentDir: agentDir, + config: InstallConfig{ + AgentName: grokAgentKey, + OrgID: "org-1", + AgentID: grokTestAgentID, + PrivateKey: "test-key", + KID: "kid-1", + BaseURL: "https://api.elydora.test", + GuardScriptPath: guardPath, + }, + configPath: configPath, + guardPath: guardPath, + hookPath: filepath.Join(agentDir, grokAuditScript), + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + } +} + +func readGrokTestObject(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +func grokTestManagedHandler(t *testing.T, settings map[string]any, event, scriptName string) map[string]any { + t.Helper() + hooks := requireObject(t, settings["hooks"]) + for _, groupValue := range requireArray(t, hooks[event]) { + group := requireObject(t, groupValue) + if _, hasMatcher := group["matcher"]; hasMatcher { + continue + } + for _, handlerValue := range requireArray(t, group["hooks"]) { + handler := requireObject(t, handlerValue) + command, _ := handler["command"].(string) + if strings.Contains(command, scriptName) { + return handler + } + } + } + t.Fatalf("%s handler for %s not found", event, scriptName) + return nil +} + +func runGrokCommand(t *testing.T, command, homeDir, payload string) (int, string) { + t.Helper() + var process *exec.Cmd + if runtime.GOOS == "windows" { + commandFile := filepath.Join(t.TempDir(), "run-grok-hook.cmd") + if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil { + t.Fatalf("write Grok command file: %v", err) + } + process = exec.Command("cmd.exe", "/d", "/c", commandFile) + } else { + process = exec.Command("sh", "-c", command) + } + process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) + process.Stdin = bytes.NewBufferString(payload) + var stderr bytes.Buffer + process.Stderr = &stderr + err := process.Run() + if err == nil { + return 0, stderr.String() + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode(), stderr.String() + } + t.Fatalf("run Grok hook command: %v", err) + return -1, stderr.String() +} + +func writeGrokTestObject(t *testing.T, path string, value map[string]any) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal Grok config: %v", err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatalf("write Grok config: %v", err) + } +} diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index fba81fc..c60b8b2 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -13,6 +13,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}, "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, + "grok": {Name: "Grok Build", ConfigDir: "~/.grok/hooks", ConfigFile: "elydora-audit.json"}, "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"}, "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, "kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"}, @@ -33,6 +34,8 @@ func NewPlugin(agentName string) AgentPlugin { return &CursorPlugin{} case "gemini": return &GeminiPlugin{} + case "grok": + return &GrokPlugin{} case "kirocli": return &KiroCliPlugin{} case "kiroide":