feat: sync Go Grok hook integration
This commit is contained in:
1 parent
17fee3d1fa
commit
2d10a50366
8 files changed
+1141
-2
No files matched your search
@@ -260,9 +260,9 @@
|
|||||||
"adapters": {
|
"adapters": {
|
||||||
"node": true,
|
"node": true,
|
||||||
"python": true,
|
"python": true,
|
||||||
"go": false
|
"go": true
|
||||||
},
|
},
|
||||||
"delivery_state": "partial",
|
"delivery_state": "available",
|
||||||
"source_url": "https://docs.x.ai/build/features/hooks"
|
"source_url": "https://docs.x.ai/build/features/hooks"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -77,6 +77,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.
|
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
|
### Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -95,6 +97,7 @@ Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_
|
|||||||
| Copilot CLI | `copilot` |
|
| Copilot CLI | `copilot` |
|
||||||
| Cursor | `cursor` |
|
| Cursor | `cursor` |
|
||||||
| Gemini CLI | `gemini` |
|
| Gemini CLI | `gemini` |
|
||||||
|
| Grok Build | `grok` |
|
||||||
| Kiro CLI | `kirocli` |
|
| Kiro CLI | `kirocli` |
|
||||||
| Kiro IDE | `kiroide` |
|
| Kiro IDE | `kiroide` |
|
||||||
| Kimi Code | `kimi` |
|
| Kimi Code | `kimi` |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
|
|||||||
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
|
"codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
|
||||||
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
|
||||||
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.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"},
|
"kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"},
|
||||||
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
|
"kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
|
||||||
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
|
"kimi": {Name: "Kimi Code", ConfigDir: "~/.kimi-code", ConfigFile: "config.toml"},
|
||||||
@@ -33,6 +34,8 @@ func NewPlugin(agentName string) AgentPlugin {
|
|||||||
return &CursorPlugin{}
|
return &CursorPlugin{}
|
||||||
case "gemini":
|
case "gemini":
|
||||||
return &GeminiPlugin{}
|
return &GeminiPlugin{}
|
||||||
|
case "grok":
|
||||||
|
return &GrokPlugin{}
|
||||||
case "kirocli":
|
case "kirocli":
|
||||||
return &KiroCliPlugin{}
|
return &KiroCliPlugin{}
|
||||||
case "kiroide":
|
case "kiroide":
|
||||||
|
|||||||
Reference in new issue
Block a user