diff --git a/sdks/go/README.md b/sdks/go/README.md index bfa5d5b..27a89ac 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -88,7 +88,7 @@ Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the genera Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal. -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. +Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory. Auggie installation writes user-level hooks to `~/.augment/settings.json` and creates the `.cmd` or `.sh` wrappers required by its command runner. System and workspace settings remain unchanged. Sessions started with `--augment-cache-dir` load settings from that alternate directory. diff --git a/sdks/go/cmd/elydora/plugins/codex_installation.go b/sdks/go/cmd/elydora/plugins/codex_installation.go index c6d3871..31ea6f0 100644 --- a/sdks/go/cmd/elydora/plugins/codex_installation.go +++ b/sdks/go/cmd/elydora/plugins/codex_installation.go @@ -132,7 +132,7 @@ func prepareCodexInstallationChanges( }{ { paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(codexAgentKey, config.AgentID, "", false)), 0700, + []byte(generateGuardScript(codexAgentKey, config.AgentID, "", false, "")), 0700, }, {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, diff --git a/sdks/go/cmd/elydora/plugins/cursor_io.go b/sdks/go/cmd/elydora/plugins/cursor_io.go index 1a04d3c..a943573 100644 --- a/sdks/go/cmd/elydora/plugins/cursor_io.go +++ b/sdks/go/cmd/elydora/plugins/cursor_io.go @@ -194,6 +194,7 @@ func prepareCursorInstallationChanges( config.AgentID, `{"permission":"allow"}`+"\n", true, + "cursor", )), 0700, }, {filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600}, diff --git a/sdks/go/cmd/elydora/plugins/grok.go b/sdks/go/cmd/elydora/plugins/grok.go index e517934..6c4a6ae 100644 --- a/sdks/go/cmd/elydora/plugins/grok.go +++ b/sdks/go/cmd/elydora/plugins/grok.go @@ -1,110 +1,133 @@ package plugins -import "fmt" +import ( + "fmt" + "path/filepath" +) -// GrokPlugin manages Grok Build native global user hooks. -type GrokPlugin struct{} +// GrokPlugin manages Grok global user hooks. +type GrokPlugin struct { + rename renameFunc +} + +// ManagesGuardRuntime reports that Grok commits its provider guard with the +// audit runtime and user hook document. +func (p *GrokPlugin) ManagesGuardRuntime() bool { + return true +} + +// PreflightInstall validates the hook source and runtime identity before any +// directory or file is created. +func (p *GrokPlugin) PreflightInstall(config InstallConfig) error { + document, err := readGrokDocument() + if err != nil { + return err + } + _, _, err = preflightGrokInstallation(config, document) + return err +} func (p *GrokPlugin) Install(config InstallConfig) error { - if config.AgentID == "" { - return fmt.Errorf("agent ID is required") - } - document, err := readGrokConfig() + document, err := readGrokDocument() if err != nil { return err } - if config.GuardScriptPath == "" { - return fmt.Errorf("guard script path is required") - } - guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") + paths, nodePath, err := preflightGrokInstallation(config, document) if err != nil { return err } - if !guardExists { - return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) - } - hookPath, err := hookScriptPath(config.AgentID) + guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) if err != nil { return err } - if config.HookScript != "" { - hookPath = config.HookScript - } - nodePath, err := resolveNodeRuntime() + auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) if err != nil { return err } - runtimeRoot, err := grokRuntimeRoot() + hooks, err := removeManagedGrokHooks(document.hooks, "") 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) + for _, item := range []struct{ event, command string }{ + {"PreToolUse", guardCommand}, + {"PostToolUse", auditCommand}, + {"PostToolUseFailure", auditCommand}, + } { + hooks[item.event] = append(hooks[item.event], buildGrokGroup(item.command)) } - if err := writeHookJSONObjectAtomic(document.configPath, next); err != nil { - return fmt.Errorf("write Grok hooks config: %w", err) + rendered, err := renderGrokDocument(document, hooks) + if err != nil { + return fmt.Errorf("render Grok user hooks: %w", err) } - fmt.Println("Grok Build: global PreToolUse and PostToolUse hooks installed.") + changes, err := prepareGrokInstallationChanges(config, paths, rendered) + if err != nil { + return err + } + if err := writeGrokChanges( + changes, + "Install Grok hooks", + p.rename, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.configPath), + ); err != nil { + return err + } + fmt.Println( + "Grok Build: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.", + ) return nil } func (p *GrokPlugin) Uninstall(agentID string) error { - document, err := readGrokConfig() - if err != nil || !document.exists { - return err - } - runtimeRoot, err := grokRuntimeRoot() + document, err := readGrokDocument() if err != nil { return err } - hooks, changed := removeManagedGrokHooks(document.hooks, agentID, runtimeRoot) - if !changed { - return nil + hooks, err := removeManagedGrokHooks(document.hooks, agentID) + if err != nil { + return err } - next := cloneGrokObject(document.root) - if len(hooks) == 0 { - delete(next, "hooks") - } else { - next["hooks"] = renderGrokHooks(hooks) + rendered, err := renderGrokDocument(document, hooks) + if err != nil { + return fmt.Errorf("render Grok user hooks: %w", err) } - if len(next) == 0 { - return removeHookFile(document.configPath, "Grok hooks config") + change, err := prepareRenderedGrokChange(rendered) + if err != nil { + return err } - return writeHookJSONObjectAtomic(document.configPath, next) + return writeGrokChanges( + []*fileChange{change}, + "Uninstall Grok hooks", + p.rename, + "", + "", + filepath.Dir(document.configPath), + ) } func (p *GrokPlugin) Status() (PluginStatus, error) { - document, err := readGrokConfig() + configPath, pathErr := grokConfigPath() + entry := SupportedAgents[grokAgentKey] status := PluginStatus{ - AgentName: grokAgentKey, - DisplayName: "Grok Build", - ConfigPath: document.configPath, + AgentName: grokAgentKey, DisplayName: entry.Name, ConfigPath: configPath, } + if pathErr != nil { + return status, pathErr + } + document, err := readGrokDocument() if err != nil { return status, err } - runtimeRoot, err := grokRuntimeRoot() + contracts, err := grokRuntimeContracts(document.hooks) if err != nil { return status, err } - contracts := grokRuntimeContracts(document.hooks, runtimeRoot) - if len(contracts) == 0 { + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { return status, nil } - status.HookConfigured = true - status.HookScriptExists, err = grokRuntimeScriptsExist(contracts, runtimeRoot) + status.HookScriptExists, err = grokRuntimeFilesExist(contracts) if err != nil { return status, err } diff --git a/sdks/go/cmd/elydora/plugins/grok_command.go b/sdks/go/cmd/elydora/plugins/grok_command.go new file mode 100644 index 0000000..80f8a70 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_command.go @@ -0,0 +1,132 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +type grokRuntimeReference struct { + agentID string + scriptPath string +} + +func sameGrokPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if absolute, err := filepath.Abs(left); err == nil { + left = absolute + } + if absolute, err := filepath.Abs(right); err == nil { + right = absolute + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameGrokAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func buildGrokCommand(runtimePath, scriptPath string) (string, error) { + if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { + return "", fmt.Errorf( + "Grok hook commands require absolute runtime and script paths", + ) + } + if runtime.GOOS == "windows" { + return codexWindowsCommand(runtimePath, scriptPath), nil + } + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil +} + +func quoteGrokLegacyWindowsArgument(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` +} + +func readGrokLegacyWindowsArgument( + 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 + } + if command[index] == '\r' || command[index] == '\n' { + return "", start, false + } + value.WriteByte(command[index]) + } + return "", start, false +} + +func parseGrokLegacyWindowsCommand(command string) (string, string, bool) { + runtimePath, next, ok := readGrokLegacyWindowsArgument(command, 0) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + scriptPath, end, ok := readGrokLegacyWindowsArgument(command, next+1) + if !ok || end != len(command) { + return "", "", false + } + expected := quoteGrokLegacyWindowsArgument(runtimePath) + " " + + quoteGrokLegacyWindowsArgument(scriptPath) + return runtimePath, scriptPath, command == expected +} + +func parseGrokCommand(command string) (string, string, bool) { + if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + return runtimePath, scriptPath, true + } + if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { + return runtimePath, scriptPath, true + } + return parseGrokLegacyWindowsCommand(command) +} + +func isGrokNodeExecutable(path string) bool { + name := filepath.Base(path) + return name == "node" || strings.EqualFold(name, "node.exe") +} + +func grokRuntimeReferenceForCommand( + command string, + scriptName string, +) (*grokRuntimeReference, error) { + runtimePath, scriptPath, ok := parseGrokCommand(command) + if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || + !isGrokNodeExecutable(runtimePath) || + !sameGrokFileName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &grokRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil +} + +func sameGrokFileName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} diff --git a/sdks/go/cmd/elydora/plugins/grok_contract.go b/sdks/go/cmd/elydora/plugins/grok_contract.go index f479aa0..dc5e145 100644 --- a/sdks/go/cmd/elydora/plugins/grok_contract.go +++ b/sdks/go/cmd/elydora/plugins/grok_contract.go @@ -1,20 +1,29 @@ package plugins import ( + "bytes" + "encoding/json" "fmt" "math" - "path/filepath" + "reflect" "runtime" + "sort" "strings" ) const ( - grokAgentKey = "grok" - grokGuardScript = "guard.js" - grokAuditScript = "hook.js" - grokHookTimeout = float64(10) - grokConfigFile = "elydora-audit.json" - grokPOSIXApostrophe = `'"'"'` + grokAgentKey = "grok" + grokGuardScript = "guard.js" + grokAuditScript = "hook.js" + grokHookTimeout = float64(10) + grokConfigFile = "elydora-audit.json" +) + +var grokMatcherRejectingEvents = stringSet( + "SessionStart", + "SessionEnd", + "Stop", + "UserPromptSubmit", ) type grokGroup struct { @@ -29,6 +38,14 @@ type grokDocument struct { configPath string root map[string]any hooks grokHooks + raw []byte +} + +type grokRenderedDocument struct { + document *grokDocument + changed bool + next []byte + remove bool } type grokRuntimeContract struct { @@ -45,15 +62,37 @@ func cloneGrokObject(value map[string]any) map[string]any { 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) +func cloneGrokHooks(source grokHooks) grokHooks { + clone := make(grokHooks, len(source)) + for event, groups := range source { + clone[event] = append([]grokGroup(nil), groups...) + } + return clone +} + +func validateGrokHandler( + value any, + event string, + groupIndex int, + handlerIndex int, +) (map[string]any, error) { + label := fmt.Sprintf( + "Grok user hooks 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"])) + return nil, fmt.Errorf( + `%s has unsupported type %q`, + label, + fmt.Sprint(handler["type"]), + ) } if handlerType == "command" { command, ok := handler["command"].(string) @@ -67,17 +106,33 @@ func validateGrokHandler(value any, event string, groupIndex, handlerIndex int) 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) + if value, exists := handler["timeout"]; exists { + timeout, ok := value.(float64) + if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || + timeout < 0 || timeout > 9007199254740991 || + math.Trunc(timeout) != timeout { + return nil, fmt.Errorf( + "%s timeout must be a non-negative integer", + label, + ) + } + } + if value, exists := handler["env"]; exists { + environment, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s env must map names to strings", label) + } + for _, item := range environment { + if _, ok := item.(string); !ok { + return nil, fmt.Errorf("%s env must map names to strings", 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) + label := fmt.Sprintf("Grok user hooks 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) @@ -86,6 +141,13 @@ func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, erro if _, ok := matcher.(string); !ok { return grokGroup{}, fmt.Errorf("%s matcher must be a string", label) } + if _, rejects := grokMatcherRejectingEvents[event]; rejects { + return grokGroup{}, fmt.Errorf( + "%s cannot declare a matcher for %s", + label, + event, + ) + } } values, ok := object["hooks"].([]any) if !ok { @@ -93,7 +155,12 @@ func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, erro } handlers := make([]map[string]any, 0, len(values)) for handlerIndex, value := range values { - handler, err := validateGrokHandler(value, event, groupIndex, handlerIndex) + handler, err := validateGrokHandler( + value, + event, + groupIndex, + handlerIndex, + ) if err != nil { return grokGroup{}, err } @@ -109,13 +176,16 @@ func readGrokHooks(root map[string]any) (grokHooks, error) { } object, ok := value.(map[string]any) if !ok || object == nil { - return nil, fmt.Errorf(`Grok hooks config field "hooks" must be an object`) + return nil, fmt.Errorf(`Grok user hooks 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) + return nil, fmt.Errorf( + `Grok user hooks field "hooks.%s" must be an array`, + event, + ) } groups := make([]grokGroup, 0, len(values)) for groupIndex, value := range values { @@ -130,6 +200,30 @@ func readGrokHooks(root map[string]any) (grokHooks, error) { return hooks, nil } +func parseGrokDocument(configPath string, raw []byte) (*grokDocument, error) { + label := fmt.Sprintf("Grok user hooks at %s", configPath) + root, err := decodeStrictJSONObject(raw, label) + if err != nil { + return nil, err + } + hooks, err := readGrokHooks(root) + if err != nil { + return nil, err + } + return &grokDocument{ + exists: true, configPath: configPath, root: root, + hooks: hooks, raw: append([]byte(nil), raw...), + }, nil +} + +func createGrokDocument(configPath string) *grokDocument { + return &grokDocument{ + configPath: configPath, + root: map[string]any{}, + hooks: grokHooks{}, + } +} + func renderGrokHooks(hooks grokHooks) map[string]any { result := make(map[string]any, len(hooks)) for event, groups := range hooks { @@ -148,158 +242,236 @@ func renderGrokHooks(hooks grokHooks) map[string]any { return result } -func readGrokWindowsArgument(command string, start int) (string, int, bool) { - if start >= len(command) || command[start] != '"' { - return "", start, false +func buildGrokHandler(command string) map[string]any { + return map[string]any{ + "type": "command", "command": command, "timeout": grokHookTimeout, } - 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 +func buildGrokGroup(command string) grokGroup { + return grokGroup{ + object: map[string]any{}, handlers: []map[string]any{buildGrokHandler(command)}, } - 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 exactManagedGrokGroup(group grokGroup) bool { + _, hasHooks := group.object["hooks"] + return len(group.object) == 1 && hasHooks } -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 +func managedGrokReference( + handler map[string]any, + scriptName string, +) (*grokRuntimeReference, error) { + if len(handler) != 3 || handler["type"] != "command" || + handler["timeout"] != grokHookTimeout { + return nil, nil } command, ok := handler["command"].(string) if !ok { + return nil, nil + } + return grokRuntimeReferenceForCommand(command, scriptName) +} + +func managedGrokEvent(event string) (string, bool) { + switch event { + case "PreToolUse": + return grokGuardScript, true + case "PostToolUse", "PostToolUseFailure": + return grokAuditScript, true + default: 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) { + scriptName string, + agentID string, +) ([]grokGroup, error) { result := make([]grokGroup, 0, len(groups)) - changed := false for _, group := range groups { - if _, hasMatcher := group.object["matcher"]; hasMatcher { + if !exactManagedGrokGroup(group) { result = append(result, group) continue } - handlers := make([]map[string]any, 0, len(group.handlers)) - groupChanged := false + kept := make([]map[string]any, 0, len(group.handlers)) for _, handler := range group.handlers { - managedID, managed := managedGrokAgentID(handler, scriptName, runtimeRoot) - remove := managed && (agentID == "" || sameGrokAgentID(managedID, agentID)) - if remove { - changed = true - groupChanged = true - continue + reference, err := managedGrokReference(handler, scriptName) + if err != nil { + return nil, err + } + remove := reference != nil && + (agentID == "" || sameGrokAgentID(reference.agentID, agentID)) + if !remove { + kept = append(kept, handler) } - handlers = append(handlers, handler) } - if len(handlers) > 0 || !groupChanged { - result = append(result, grokGroup{object: group.object, handlers: handlers}) + if len(kept) > 0 { + result = append(result, grokGroup{ + object: map[string]any{"hooks": group.object["hooks"]}, + handlers: kept, + }) } } - return result, changed + return result, nil } -func removeManagedGrokHooks(hooks grokHooks, agentID, runtimeRoot string) (grokHooks, bool) { - result := make(grokHooks, len(hooks)) - for event, groups := range hooks { - result[event] = groups - } - changed := false +func removeManagedGrokHooks(hooks grokHooks, agentID string) (grokHooks, error) { + result := cloneGrokHooks(hooks) for _, contract := range []struct{ event, script string }{ {"PreToolUse", grokGuardScript}, {"PostToolUse", grokAuditScript}, + {"PostToolUseFailure", grokAuditScript}, } { - groups, eventChanged := removeManagedGrokGroups( - result[contract.event], contract.script, agentID, runtimeRoot, + groups, err := removeManagedGrokGroups( + result[contract.event], + contract.script, + agentID, ) - if !eventChanged { - continue + if err != nil { + return nil, err } - changed = true if len(groups) == 0 { delete(result, contract.event) } else { result[contract.event] = groups } } - return result, changed + return result, nil +} + +func entirelyManagedGrokDocument(document *grokDocument) bool { + if !document.exists || len(document.root) != 1 || len(document.hooks) == 0 { + return false + } + if _, hasHooks := document.root["hooks"]; !hasHooks { + return false + } + handlerCount := 0 + for event, groups := range document.hooks { + scriptName, managedEvent := managedGrokEvent(event) + if !managedEvent || len(groups) == 0 { + return false + } + for _, group := range groups { + if !exactManagedGrokGroup(group) || len(group.handlers) == 0 { + return false + } + for _, handler := range group.handlers { + reference, err := managedGrokReference(handler, scriptName) + if err != nil || reference == nil { + return false + } + handlerCount++ + } + } + } + return handlerCount > 0 +} + +func renderGrokDocument( + document *grokDocument, + hooks grokHooks, +) (*grokRenderedDocument, error) { + if !document.exists && len(hooks) == 0 { + return &grokRenderedDocument{document: document}, nil + } + if reflect.DeepEqual(hooks, document.hooks) { + return &grokRenderedDocument{document: document}, nil + } + if len(hooks) == 0 && entirelyManagedGrokDocument(document) { + return &grokRenderedDocument{ + document: document, changed: true, remove: true, + }, nil + } + root := cloneGrokObject(document.root) + if len(hooks) == 0 { + delete(root, "hooks") + } else { + root["hooks"] = renderGrokHooks(hooks) + } + next, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Grok user hooks: %w", err) + } + next = append(next, '\n') + if _, err := parseGrokDocument(document.configPath, next); err != nil { + return nil, fmt.Errorf("validate rendered Grok user hooks: %w", err) + } + return &grokRenderedDocument{ + document: document, + changed: !document.exists || !bytes.Equal(next, document.raw), + next: next, + }, nil +} + +func grokReferenceKey(agentID string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(agentID) + } + return agentID +} + +func grokReferencesForEvent( + groups []grokGroup, + scriptName string, +) (map[string][]grokRuntimeReference, error) { + result := map[string][]grokRuntimeReference{} + for _, group := range groups { + if !exactManagedGrokGroup(group) { + continue + } + for _, handler := range group.handlers { + reference, err := managedGrokReference(handler, scriptName) + if err != nil { + return nil, err + } + if reference == nil { + continue + } + key := grokReferenceKey(reference.agentID) + result[key] = append(result[key], *reference) + } + } + return result, nil +} + +func grokRuntimeContracts(hooks grokHooks) ([]grokRuntimeContract, error) { + guards, err := grokReferencesForEvent(hooks["PreToolUse"], grokGuardScript) + if err != nil { + return nil, err + } + successes, err := grokReferencesForEvent(hooks["PostToolUse"], grokAuditScript) + if err != nil { + return nil, err + } + failures, err := grokReferencesForEvent( + hooks["PostToolUseFailure"], + grokAuditScript, + ) + if err != nil { + return nil, err + } + keys := make([]string, 0, len(guards)) + for key := range guards { + keys = append(keys, key) + } + sort.Strings(keys) + contracts := make([]grokRuntimeContract, 0, len(keys)) + for _, key := range keys { + guard := guards[key] + success := successes[key] + failure := failures[key] + if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || + !sameGrokPath(success[0].scriptPath, failure[0].scriptPath) { + continue + } + contracts = append(contracts, grokRuntimeContract{ + agentID: guard[0].agentID, guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }) + } + return contracts, nil } diff --git a/sdks/go/cmd/elydora/plugins/grok_installation.go b/sdks/go/cmd/elydora/plugins/grok_installation.go new file mode 100644 index 0000000..01bb50a --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_installation.go @@ -0,0 +1,171 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type grokRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string +} + +func validateGrokInstallConfig(config InstallConfig) error { + for _, field := range []struct{ name, value string }{ + {"agent name", config.AgentName}, + {"organization ID", config.OrgID}, + {"agent ID", config.AgentID}, + {"key ID", config.KID}, + {"private key", config.PrivateKey}, + {"base URL", config.BaseURL}, + {"guard script path", config.GuardScriptPath}, + } { + if field.value == "" { + return fmt.Errorf("%s is required", field.name) + } + } + if config.AgentName != grokAgentKey { + return fmt.Errorf("grok installation requires agent name %s", grokAgentKey) + } + if strings.TrimSpace(config.OrgID) == "" { + return fmt.Errorf("organization ID is required") + } + if strings.TrimSpace(config.KID) == "" { + return fmt.Errorf("key ID is required") + } + if config.Token != "" && strings.TrimSpace(config.Token) == "" { + return fmt.Errorf("token must contain a non-whitespace value when provided") + } + if err := validateManagedPrivateKey(config.PrivateKey); err != nil { + return err + } + return validateManagedBaseURL(config.BaseURL) +} + +func grokAgentPaths(config InstallConfig) (*grokRuntimePaths, error) { + if err := validateGrokInstallConfig(config); err != nil { + return nil, err + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + paths := &grokRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, grokGuardScript), + auditPath: filepath.Join(agentDirectory, grokAuditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameGrokPath(config.GuardScriptPath, paths.guardPath) { + return nil, fmt.Errorf( + "guard runtime must use the managed Elydora agent directory: %s", + paths.guardPath, + ) + } + if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || + !sameGrokPath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "audit runtime must use the managed Elydora agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightGrokInstallation( + config InstallConfig, + document *grokDocument, +) (*grokRuntimePaths, string, error) { + if document == nil { + return nil, "", fmt.Errorf("grok installation requires a hook document") + } + paths, err := grokAgentPaths(config) + if err != nil { + return nil, "", err + } + if err := validateGrokRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + return nil, "", err + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + return paths, nodePath, nil +} + +func buildGrokRuntimeConfig(config InstallConfig) ([]byte, error) { + value := map[string]any{ + "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, + "base_url": config.BaseURL, "agent_name": grokAgentKey, + } + if config.Token != "" { + value["token"] = config.Token + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + encoded = append(encoded, '\n') + if len(encoded) > maxRuntimeConfigBytes { + return nil, fmt.Errorf( + "elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + return encoded, nil +} + +func prepareGrokInstallationChanges( + config InstallConfig, + paths *grokRuntimePaths, + rendered *grokRenderedDocument, +) ([]*fileChange, error) { + runtimeConfig, err := buildGrokRuntimeConfig(config) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + { + paths.guardPath, "Elydora guard runtime", + []byte(generateGuardScript(grokAgentKey, config.AgentID, "", false, "grok")), 0700, + }, + {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, + {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, + { + paths.auditPath, "Elydora audit runtime", + []byte(buildHookScriptWithOutput( + grokAgentKey, config.AgentID, "", false, true, + )), 0700, + }, + } + changes := make([]*fileChange, 0, len(items)+1) + for _, item := range items { + change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + documentChange, err := prepareRenderedGrokChange(rendered) + if err != nil { + return nil, err + } + return append(changes, documentChange), nil +} diff --git a/sdks/go/cmd/elydora/plugins/grok_io.go b/sdks/go/cmd/elydora/plugins/grok_io.go index c7d6159..0ae9323 100644 --- a/sdks/go/cmd/elydora/plugins/grok_io.go +++ b/sdks/go/cmd/elydora/plugins/grok_io.go @@ -4,178 +4,113 @@ import ( "fmt" "os" "path/filepath" - "runtime" - "sort" - "strings" ) -func grokHomeDirectory() (string, error) { - home, err := os.UserHomeDir() +func grokHomePath() (string, error) { + configured := os.Getenv("GROK_HOME") + if configured == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".grok"), nil + } + home, err := filepath.Abs(configured) if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) + return "", fmt.Errorf("resolve GROK_HOME at %s: %w", configured, 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() + home, err := grokHomePath() 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 + return filepath.Join(home, "hooks", grokConfigFile), nil } -func readGrokConfig() (grokDocument, error) { +func readGrokDocument() (*grokDocument, error) { configPath, err := grokConfigPath() if err != nil { - return grokDocument{}, err + return nil, err } - root, exists, err := readHookJSONObject(configPath, "Grok hooks config") + home := filepath.Dir(filepath.Dir(configPath)) + if _, err := managedPhysicalDirectoryExists(home, "Grok home directory"); err != nil { + return nil, err + } + hooksDirectory := filepath.Dir(configPath) + if _, err := managedPhysicalDirectoryExists( + hooksDirectory, + "Grok hooks directory", + ); err != nil { + return nil, err + } + snapshot, err := readManagedFile( + configPath, + "Grok user hooks", + maxManagedSourceBytes, + ) if err != nil { - return grokDocument{}, err + return nil, err } - hooks, err := readGrokHooks(root) - if err != nil { - return grokDocument{}, err + if snapshot == nil { + return createGrokDocument(configPath), nil } - return grokDocument{exists: exists, configPath: configPath, root: root, hooks: hooks}, nil + return parseGrokDocument(configPath, snapshot.contents) } -func quoteGrokWindowsArgument(value string) string { - return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"` +func prepareRenderedGrokChange( + rendered *grokRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSourceChange( + rendered.document.configPath, + "Grok user hooks", + rendered.document.raw, + rendered.document.exists, + rendered.next, + 0600, + rendered.remove, + ) } -func buildGrokCommand(runtimePath, scriptPath string) string { - if runtime.GOOS == "windows" { - return quoteGrokWindowsArgument(runtimePath) + " " + quoteGrokWindowsArgument(scriptPath) +func writeGrokChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot string, + agentDirectory string, + hooksDirectory string, +) error { + hasChanges := false + for _, change := range changes { + if change != nil { + hasChanges = true + break + } } - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) -} - -func buildGrokHandler(command string) map[string]any { - return map[string]any{ - "type": "command", - "command": command, - "timeout": grokHookTimeout, + if !hasChanges { + return nil } -} - -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 + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err } - 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 - } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err } } - 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) + if err := ensureManagedDirectory( + filepath.Dir(hooksDirectory), + "Grok home directory", + ); err != nil { + return 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 + if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil { + return err } - return false, nil + return writeChanges(changes, label, rename) } diff --git a/sdks/go/cmd/elydora/plugins/grok_official_test.go b/sdks/go/cmd/elydora/plugins/grok_official_test.go new file mode 100644 index 0000000..cc85631 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_official_test.go @@ -0,0 +1,74 @@ +package plugins + +import ( + "encoding/json" + "os" + "os/exec" + "reflect" + "testing" +) + +func TestGrokOfficialBinaryReadsManagedTriple(t *testing.T) { + binary := os.Getenv("ELYDORA_GROK_BINARY") + if binary == "" { + t.Skip("ELYDORA_GROK_BINARY is unset") + } + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + command := exec.Command(binary, "inspect", "--json") + command.Dir = fixture.projectDir + command.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "GROK_HOME="+fixture.grokHome, + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("inspect official Grok config: %v\n%s", err, output) + } + var report struct { + Hooks []struct { + Event string `json:"event"` + HookType string `json:"hookType"` + Target string `json:"target"` + Matcher any `json:"matcher"` + Source struct { + Type string `json:"type"` + } `json:"source"` + } `json:"hooks"` + } + if err := json.Unmarshal(output, &report); err != nil { + t.Fatalf("decode official Grok inspection: %v\n%s", err, output) + } + if len(report.Hooks) != 3 { + t.Fatalf("official Grok hook count = %d\n%s", len(report.Hooks), output) + } + wantTargets := map[string]string{} + settings := readGrokTestObject(t, fixture.configPath) + for _, item := range []struct{ event, script string }{ + {"PreToolUse", grokGuardScript}, + {"PostToolUse", grokAuditScript}, + {"PostToolUseFailure", grokAuditScript}, + } { + wantTargets[item.event] = grokTestManagedHandler( + t, + settings, + item.event, + item.script, + )["command"].(string) + } + actual := map[string]string{} + for _, hook := range report.Hooks { + if hook.HookType != "command" || hook.Matcher != nil || + hook.Source.Type != "user" { + t.Fatalf("official Grok hook shape = %#v", hook) + } + actual[hook.Event] = hook.Target + } + if !reflect.DeepEqual(actual, wantTargets) { + t.Fatalf("official Grok hooks = %#v, want %#v", actual, wantTargets) + } +} diff --git a/sdks/go/cmd/elydora/plugins/grok_runtime_test.go b/sdks/go/cmd/elydora/plugins/grok_runtime_test.go new file mode 100644 index 0000000..7e51caf --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_runtime_test.go @@ -0,0 +1,230 @@ +package plugins + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +func installGrokRuntimeFixture( + t *testing.T, + status string, +) (*grokFixture, *codexTestAPI, *httptest.Server) { + t.Helper() + api := &codexTestAPI{status: status} + server := httptest.NewServer(api) + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + fixture.config.BaseURL = server.URL + fixture.config.Token = "ely_test_token" + if err := fixture.plugin.Install(fixture.config); err != nil { + server.Close() + t.Fatalf("install Grok hooks: %v", err) + } + return fixture, api, server +} + +func TestGrokGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) { + fixture, api, server := installGrokRuntimeFixture(t, "active") + defer server.Close() + settings := readGrokTestObject(t, fixture.configPath) + guard := grokTestManagedHandler(t, settings, "PreToolUse", grokGuardScript) + success := grokTestManagedHandler(t, settings, "PostToolUse", grokAuditScript) + failure := grokTestManagedHandler(t, settings, "PostToolUseFailure", grokAuditScript) + + if exitCode, stdout, stderr := runGrokCommand( + t, + guard["command"].(string), + fixture, + grokOfficialPayload("PreToolUse"), + ); exitCode != 0 || stdout != "" || stderr != "" { + t.Fatalf("guard result = %d, %q, %q", exitCode, stdout, stderr) + } + successPayload := grokOfficialPayload("PostToolUse") + failurePayload := grokOfficialPayload("PostToolUseFailure") + for _, item := range []struct { + command string + payload map[string]any + }{ + {success["command"].(string), successPayload}, + {failure["command"].(string), failurePayload}, + } { + if exitCode, stdout, stderr := runGrokCommand( + t, + item.command, + fixture, + item.payload, + ); exitCode != 0 || stdout != "" || stderr != "" { + t.Fatalf("audit result = %d, %q, %q", exitCode, stdout, stderr) + } + } + + getAuth, postAuth, operations := api.snapshot() + if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" { + t.Fatalf("authorization headers = %q, %q", getAuth, postAuth) + } + if len(operations) != 2 || + !reflect.DeepEqual(operations[0]["payload"], successPayload) || + !reflect.DeepEqual(operations[1]["payload"], failurePayload) { + t.Fatalf("Grok native operations = %#v", operations) + } + if operations[0]["prev_chain_hash"] != + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" || + operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] { + t.Fatalf("Grok operation chain = %#v", operations) + } +} + +func TestGrokGuardEmitsOfficialDenyJSONAndExitCode(t *testing.T) { + for _, status := range []string{"frozen", "revoked"} { + t.Run(status, func(t *testing.T) { + fixture, _, server := installGrokRuntimeFixture(t, status) + defer server.Close() + guard := grokTestManagedHandler( + t, + readGrokTestObject(t, fixture.configPath), + "PreToolUse", + grokGuardScript, + ) + exitCode, stdout, stderr := runGrokCommand( + t, + guard["command"].(string), + fixture, + grokOfficialPayload("PreToolUse"), + ) + if exitCode != 2 || !strings.Contains(strings.ToLower(stderr), status) { + t.Fatalf("guard result = %d, %q, %q", exitCode, stdout, stderr) + } + decision := map[string]any{} + if err := json.Unmarshal([]byte(stdout), &decision); err != nil { + t.Fatalf("decode Grok denial: %v", err) + } + keys := make([]string, 0, len(decision)) + for key := range decision { + keys = append(keys, key) + } + sort.Strings(keys) + if !reflect.DeepEqual(keys, []string{"decision", "reason"}) || + decision["decision"] != "deny" || + !strings.Contains(strings.ToLower(decision["reason"].(string)), status) { + t.Fatalf("Grok denial = %#v", decision) + } + }) + } +} + +func TestGrokFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) { + fixture, api, server := installGrokRuntimeFixture(t, "active") + guard := grokTestManagedHandler( + t, + readGrokTestObject(t, fixture.configPath), + "PreToolUse", + grokGuardScript, + ) + command := guard["command"].(string) + runtimeConfig, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read runtime config: %v", err) + } + + malformedExit, _, malformedError := runGrokRawCommand( + t, + command, + fixture, + []byte("{ malformed"), + ) + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + configExit, _, configError := runGrokCommand( + t, + command, + fixture, + grokOfficialPayload("PreToolUse"), + ) + if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil { + t.Fatalf("restore runtime config: %v", err) + } + _ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json")) + api.mu.Lock() + api.status = "unknown" + api.mu.Unlock() + statusExit, _, statusError := runGrokCommand( + t, + command, + fixture, + grokOfficialPayload("PreToolUse"), + ) + _ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json")) + server.Close() + apiExit, _, apiError := runGrokCommand( + t, + command, + fixture, + grokOfficialPayload("PreToolUse"), + ) + + for _, result := range []struct { + exit int + err string + want string + }{ + {malformedExit, malformedError, "invalid JSON"}, + {configExit, configError, "Failed to read agent config"}, + {statusExit, statusError, "invalid agent status"}, + {apiExit, apiError, "Failed to resolve agent status"}, + } { + if result.exit != 0 || !strings.Contains(result.err, result.want) { + t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want) + } + } +} + +func TestGrokAuditRecordsMalformedInputAndAPIFailures(t *testing.T) { + fixture, api, server := installGrokRuntimeFixture(t, "active") + defer server.Close() + command := grokTestManagedHandler( + t, + readGrokTestObject(t, fixture.configPath), + "PostToolUseFailure", + grokAuditScript, + )["command"].(string) + + malformedExit, malformedOutput, malformedError := runGrokRawCommand( + t, + command, + fixture, + []byte("{ malformed"), + ) + api.mu.Lock() + api.postStatus = http.StatusServiceUnavailable + api.mu.Unlock() + upstreamExit, upstreamOutput, upstreamError := runGrokCommand( + t, + command, + fixture, + grokOfficialPayload("PostToolUseFailure"), + ) + log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log")) + if malformedExit != 0 || malformedOutput != "" || malformedError != "" || + upstreamExit != 0 || upstreamOutput != "" || upstreamError != "" || + err != nil || !strings.Contains(string(log), "invalid JSON") || + !strings.Contains(string(log), "HTTP 503") { + t.Fatalf( + "audit failures = (%d, %q, %q), (%d, %q, %q), log=%q, err=%v", + malformedExit, + malformedOutput, + malformedError, + upstreamExit, + upstreamOutput, + upstreamError, + log, + err, + ) + } +} diff --git a/sdks/go/cmd/elydora/plugins/grok_security_test.go b/sdks/go/cmd/elydora/plugins/grok_security_test.go new file mode 100644 index 0000000..7f8b912 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_security_test.go @@ -0,0 +1,273 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGrokRejectsLinkedHookFileBeforeRuntimeWrites(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + target := filepath.Join(filepath.Dir(fixture.homeDir), "hooks-target.json") + source := []byte("{\"owner\":\"protected\"}\n") + if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { + t.Fatalf("create Grok hooks directory: %v", err) + } + if err := os.WriteFile(target, source, 0600); err != nil { + t.Fatalf("write hook target: %v", err) + } + grokSymlinkOrSkip(t, target, fixture.configPath) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "not a physical file") { + t.Fatalf("install error = %v", err) + } + current, readErr := os.ReadFile(target) + if readErr != nil || string(current) != string(source) { + t.Fatalf("linked target changed: %q, %v", current, readErr) + } + assertNoGrokRuntimeWrites(t, fixture) +} + +func TestGrokRejectsLinkedHomeHooksAndRuntimeDirectories(t *testing.T) { + for _, kind := range []string{"home", "hooks", "runtime"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + target := filepath.Join(filepath.Dir(fixture.homeDir), kind+"-target") + if err := os.MkdirAll(target, 0700); err != nil { + t.Fatalf("create symlink target: %v", err) + } + var linkPath string + switch kind { + case "home": + linkPath = fixture.grokHome + if err := os.MkdirAll(filepath.Join(target, "hooks"), 0700); err != nil { + t.Fatalf("create target hooks: %v", err) + } + if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil { + t.Fatalf("create home parent: %v", err) + } + case "hooks": + linkPath = filepath.Dir(fixture.configPath) + if err := os.MkdirAll(fixture.grokHome, 0700); err != nil { + t.Fatalf("create Grok home: %v", err) + } + case "runtime": + linkPath = filepath.Join(fixture.homeDir, ".elydora") + if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil { + t.Fatalf("create runtime parent: %v", err) + } + } + grokSymlinkOrSkip(t, target, linkPath) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("install error = %v", err) + } + if kind == "runtime" { + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("hook config exists after runtime rejection: %v", err) + } + } else { + assertNoGrokRuntimeWrites(t, fixture) + } + }) + } +} + +func TestGrokRejectsLinkedRuntimeArtifacts(t *testing.T) { + for _, name := range []string{ + "config.json", "private.key", "guard.js", "hook.js", "chain-state.json", + "status-cache.json", "error.log", + } { + t.Run(name, func(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + path := filepath.Join(fixture.agentDir, name) + target := filepath.Join(filepath.Dir(fixture.homeDir), "target-"+name) + source := []byte("protected") + if current, err := os.ReadFile(path); err == nil { + source = current + } + if err := os.WriteFile(target, source, 0600); err != nil { + t.Fatalf("write runtime target: %v", err) + } + _ = os.Remove(path) + grokSymlinkOrSkip(t, target, path) + var err error + if name == "private.key" { + _, err = fixture.plugin.Status() + } else { + err = fixture.plugin.PreflightInstall(fixture.config) + } + if err == nil || !strings.Contains(err.Error(), "not a physical file") { + t.Fatalf("linked %s error = %v", name, err) + } + current, readErr := os.ReadFile(target) + if readErr != nil || string(current) != string(source) { + t.Fatalf("linked target changed: %q, %v", current, readErr) + } + }) + } +} + +func TestGrokRejectsOrphanedAndMismatchedRuntimeIdentity(t *testing.T) { + for _, kind := range []string{"orphaned", "mismatched"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := os.MkdirAll(fixture.agentDir, 0700); err != nil { + t.Fatalf("create agent runtime: %v", err) + } + if kind == "orphaned" { + if err := os.WriteFile(fixture.guardPath, []byte("orphaned\n"), 0700); err != nil { + t.Fatalf("write orphaned guard: %v", err) + } + } else { + if err := os.WriteFile( + fixture.runtimeConfig, + []byte(`{"agent_name":"grok","agent_id":"another-agent"}`), + 0600, + ); err != nil { + t.Fatalf("write mismatched config: %v", err) + } + } + err := fixture.plugin.Install(fixture.config) + want := "identity cannot be verified" + if kind == "mismatched" { + want = "identity does not match" + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("install error = %v, want %q", err, want) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("hook config exists after identity rejection: %v", err) + } + }) + } +} + +func TestGrokValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { + for _, testCase := range []struct { + name string + edit func(*grokFixture) + want string + }{ + {"agent name", func(f *grokFixture) { f.config.AgentName = "codex" }, "requires agent name grok"}, + {"organization", func(f *grokFixture) { f.config.OrgID = " " }, "organization ID is required"}, + {"key ID", func(f *grokFixture) { f.config.KID = "\t" }, "key ID is required"}, + {"token", func(f *grokFixture) { f.config.Token = " " }, "token must contain"}, + {"private key", func(f *grokFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"}, + {"base URL", func(f *grokFixture) { + f.config.BaseURL = "https://api.elydora.com/path?token=secret" + }, "query parameters"}, + {"guard path", func(f *grokFixture) { + f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") + }, "managed Elydora agent directory"}, + {"audit path", func(f *grokFixture) { + f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") + }, "managed Elydora agent directory"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + testCase.edit(fixture) + 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) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("hook config exists after input rejection: %v", err) + } + assertNoGrokRuntimeWrites(t, fixture) + }) + } +} + +func TestGrokStatusSurfacesMalformedAndInvalidRuntimeMetadata(t *testing.T) { + for _, testCase := range []struct{ name, source, want string }{ + {"malformed", "{ malformed", "parse Elydora runtime config"}, + {"unsupported", `{"org_id":"o","agent_id":"agent-1","kid":"k","base_url":"https://api.test","agent_name":"grok","extra":true}`, "unsupported field"}, + {"identity", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"grok"}`, "identity does not match"}, + } { + t.Run(testCase.name, func(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(testCase.source), 0600); err != nil { + t.Fatalf("replace runtime config: %v", err) + } + _, err := fixture.plugin.Status() + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("status error = %v, want %q", err, testCase.want) + } + }) + } +} + +func TestGrokStatusRequiresEveryRuntimeFileAndCanonicalPrivateKey(t *testing.T) { + for _, name := range []string{"config.json", "private.key", "guard.js", "hook.js"} { + t.Run("missing "+name, func(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.Remove(filepath.Join(fixture.agentDir, name)); err != nil { + t.Fatalf("remove %s: %v", name, err) + } + status, err := fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || + status.HookScriptExists { + t.Fatalf("missing %s status = %#v, %v", name, status, err) + } + }) + } + 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.privateKey, []byte("invalid"), 0600); err != nil { + t.Fatalf("corrupt private key: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || + !strings.Contains(err.Error(), "canonical 32-byte") { + t.Fatalf("invalid key status error = %v", err) + } +} + +func TestGrokOwnershipRequiresExactGroupAndHandlerFields(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + guard := legacyGrokCommand(t, fixture.guardPath) + audit := legacyGrokCommand(t, fixture.hookPath) + existing := map[string]any{"hooks": map[string]any{ + "PreToolUse": []any{map[string]any{ + "label": "user", "hooks": []any{buildGrokHandler(guard)}, + }}, + "PostToolUse": []any{ + map[string]any{"hooks": []any{map[string]any{ + "type": "command", "command": audit, "timeout": float64(10), + "label": "user", + }}}, + }, + }} + if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { + t.Fatalf("create Grok hooks directory: %v", err) + } + writeGrokTestObject(t, fixture.configPath, existing) + 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) + } + remaining := readGrokTestObject(t, fixture.configPath) + hooks := requireObject(t, remaining["hooks"]) + if len(requireArray(t, hooks["PreToolUse"])) != 1 || + len(requireArray(t, hooks["PostToolUse"])) != 1 { + t.Fatalf("ownership lookalikes changed: %#v", hooks) + } + if _, exists := hooks["PostToolUseFailure"]; exists { + t.Fatalf("managed failure hook remains: %#v", hooks) + } +} diff --git a/sdks/go/cmd/elydora/plugins/grok_status.go b/sdks/go/cmd/elydora/plugins/grok_status.go new file mode 100644 index 0000000..abb696e --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_status.go @@ -0,0 +1,232 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +func readGrokRuntimeConfig(path string) (map[string]any, bool, error) { + snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) + if err != nil || snapshot == nil { + return nil, snapshot != nil, err + } + label := fmt.Sprintf("Elydora runtime config at %s", path) + config, err := decodeStrictJSONObject(snapshot.contents, label) + return config, true, err +} + +func requireGrokRuntimeString( + config map[string]any, + field string, + configPath string, +) (string, error) { + value, ok := config[field].(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf( + "elydora runtime config %s is invalid: %s", + field, + configPath, + ) + } + return value, nil +} + +func validateGrokRuntimeConfig( + config map[string]any, + expectedAgentID string, + configPath string, +) error { + supported := stringSet( + "org_id", "agent_id", "kid", "base_url", "token", "agent_name", + ) + fields := make([]string, 0, len(config)) + for field := range config { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + if _, ok := supported[field]; !ok { + return fmt.Errorf( + `elydora runtime config has unsupported field %q: %s`, + field, + configPath, + ) + } + } + if _, err := requireGrokRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireGrokRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireGrokRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameGrokAgentID(agentID, expectedAgentID) || + config["agent_name"] != grokAgentKey { + return fmt.Errorf( + "elydora runtime identity does not match Grok hooks: %s", + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireGrokRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireGrokRuntimeString(config, "base_url", configPath) + if err != nil { + return err + } + if err := validateManagedBaseURL(baseURL); err != nil { + return fmt.Errorf( + "elydora runtime config base URL is invalid at %s: %w", + configPath, + err, + ) + } + return nil +} + +func validateGrokRuntimeIdentity(agentDirectory, agentID string) error { + runtimeRoot := filepath.Dir(agentDirectory) + rootExists, err := managedPhysicalDirectoryExists( + runtimeRoot, + "Elydora runtime directory", + ) + if err != nil || !rootExists { + return err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, + "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return err + } + configPath := filepath.Join(agentDirectory, "config.json") + config, configExists, err := readGrokRuntimeConfig(configPath) + if err != nil { + return err + } + artifactExists := false + for _, item := range []struct { + path string + label string + limit int64 + }{ + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, + {filepath.Join(agentDirectory, grokGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, grokAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, + } { + exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) + if inspectErr != nil { + return inspectErr + } + artifactExists = artifactExists || exists + } + if !configExists { + if artifactExists { + return fmt.Errorf( + "elydora runtime identity cannot be verified without config.json: %s", + agentDirectory, + ) + } + return nil + } + configuredID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != grokAgentKey || + !sameGrokAgentID(configuredID, agentID) { + return fmt.Errorf( + "elydora runtime config identity does not match Grok agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func grokRuntimeContractExists(contract grokRuntimeContract) (bool, error) { + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return false, err + } + agentDirectory := filepath.Dir(contract.guardPath) + if !sameGrokPath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameGrokPath( + contract.auditPath, + filepath.Join(agentDirectory, grokAuditScript), + ) { + return false, nil + } + rootExists, err := managedPhysicalDirectoryExists( + runtimeRoot, + "Elydora runtime directory", + ) + if err != nil || !rootExists { + return false, err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, + "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return false, err + } + + configPath := filepath.Join(agentDirectory, "config.json") + keyPath := filepath.Join(agentDirectory, "private.key") + config, configExists, err := readGrokRuntimeConfig(configPath) + if err != nil { + return false, err + } + key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) + if err != nil { + return false, err + } + guard, err := readManagedFile( + contract.guardPath, + "Elydora guard runtime", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + audit, err := readManagedFile( + contract.auditPath, + "Elydora audit runtime", + maxManagedSourceBytes, + ) + if err != nil { + return false, err + } + if !configExists || key == nil || guard == nil || audit == nil { + return false, nil + } + if err := validateGrokRuntimeConfig(config, contract.agentID, configPath); err != nil { + return false, err + } + if err := validateManagedPrivateKey(string(key.contents)); err != nil { + return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) + } + return len(guard.contents) > 0 && len(audit.contents) > 0, nil +} + +func grokRuntimeFilesExist(contracts []grokRuntimeContract) (bool, error) { + for _, contract := range contracts { + exists, err := grokRuntimeContractExists(contract) + if err != nil { + return false, err + } + if exists { + return true, nil + } + } + return false, nil +} diff --git a/sdks/go/cmd/elydora/plugins/grok_test.go b/sdks/go/cmd/elydora/plugins/grok_test.go index 9389073..613e20d 100644 --- a/sdks/go/cmd/elydora/plugins/grok_test.go +++ b/sdks/go/cmd/elydora/plugins/grok_test.go @@ -1,7 +1,6 @@ package plugins import ( - "encoding/json" "os" "path/filepath" "reflect" @@ -10,37 +9,50 @@ import ( "testing" ) -func TestGrokRegistryAndFactory(t *testing.T) { +func TestGrokRegistryFactoryAndRuntimeOwnership(t *testing.T) { entry := SupportedAgents[grokAgentKey] - if entry.Name != "Grok Build" || entry.ConfigDir != "~/.grok/hooks" || entry.ConfigFile != grokConfigFile { + 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)) + plugin, ok := NewPlugin(grokAgentKey).(*GrokPlugin) + if !ok || !plugin.ManagesGuardRuntime() { + t.Fatalf("Grok plugin = %#v", 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) +func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) } - 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) + command, err := buildGrokCommand(nodePath, fixture.guardPath) + if err != nil { + t.Fatalf("build Grok command: %v", err) } - - 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) + executable, script, ok := parseGrokCommand(command) + if !ok || !sameGrokPath(executable, nodePath) || + !sameGrokPath(script, fixture.guardPath) { + t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, 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) + if strings.Contains(command, "%ELYDORA_HOOK_PATH%") { + t.Fatalf("Grok command exposes an expandable path: %q", command) + } + if _, err := buildGrokCommand("node", fixture.guardPath); err == nil { + t.Fatal("accepted a relative Grok runtime path") + } + if _, err := buildGrokCommand(nodePath, "guard.js"); err == nil { + t.Fatal("accepted a relative Grok script path") + } + for _, invalid := range []string{ + command + " --inspect", + command + "\nexit 0", + "node " + fixture.guardPath, + } { + if _, _, ok := parseGrokCommand(invalid); ok { + t.Fatalf("accepted non-canonical command %q", invalid) + } } } @@ -48,56 +60,52 @@ 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}]}] + "SessionStart": [{"hooks":[{"type":"http","url":"https://example.test/hook","timeout":0}],"label":"keep"}], + "PreToolUse": [{"matcher":"Bash|run_terminal_command","hooks":[{"type":"command","command":"existing-command","timeout":5,"env":{"A":"B"}}]}] } }` fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing}) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Grok hooks: %v", err) } + first, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read first Grok config: %v", err) + } if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("repeat Grok install: %v", err) } + second, err := os.ReadFile(fixture.configPath) + if err != nil || string(second) != string(first) { + t.Fatalf("repeat install changed config: %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"]) + len(requireArray(t, hooks["PreToolUse"])) != 2 { + t.Fatalf("user hooks changed: %#v", hooks) } + requireStrictGrokTriple(t, settings) for _, path := range []string{ + filepath.Join(fixture.homeDir, ".grok", "hooks", grokConfigFile), 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) + if _, err := os.Lstat(path); !os.IsNotExist(err) { + t.Fatalf("compatibility config was written at %s: %v", path, err) } } + for _, path := range []string{ + fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey, + } { + if info, err := os.Lstat(path); err != nil || !info.Mode().IsRegular() { + t.Fatalf("managed runtime %s = %v, %v", path, info, err) + } + } + assertNoGrokTransactionArtifacts(t, fixture.homeDir) } func TestGrokEmptyHomeOverrideUsesDefault(t *testing.T) { @@ -106,208 +114,166 @@ func TestGrokEmptyHomeOverrideUsesDefault(t *testing.T) { t.Fatalf("install Grok hooks: %v", err) } status, err := fixture.plugin.Status() - if err != nil || !status.Installed { + if err != nil || !status.Installed || status.ConfigPath != fixture.configPath { t.Fatalf("default-home status = %#v, %v", status, err) } } -func TestGrokCommandsBlockAndForwardPayloadByteForByte(t *testing.T) { +func TestGrokMigratesLegacyCommandsAndPreservesLookalikes(t *testing.T) { fixture := prepareGrokFixture(t, grokFixtureOptions{}) + legacyGuard := legacyGrokCommand(t, fixture.guardPath) + legacyAudit := legacyGrokCommand(t, fixture.hookPath) + lookalike := legacyGuard + " --inspect" + existing := map[string]any{"hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{"hooks": []any{buildGrokHandler(legacyGuard)}}, + map[string]any{"hooks": []any{buildGrokHandler(lookalike)}}, + }, + "PostToolUse": []any{ + map[string]any{"hooks": []any{buildGrokHandler(legacyAudit)}}, + }, + }} + if err := os.MkdirAll(filepath.Dir(fixture.configPath), 0700); err != nil { + t.Fatalf("create Grok hooks directory: %v", err) + } + writeGrokTestObject(t, fixture.configPath, existing) 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) + t.Fatalf("migrate Grok hooks: %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) + requireStrictGrokTriple(t, settings) + raw, _ := os.ReadFile(fixture.configPath) + if !strings.Contains(string(raw), "--inspect") { + t.Fatalf("ownership lookalike was removed: %s", raw) } - 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)))}}, + groups := requireArray(t, hooks["PreToolUse"]) + managed := requireObject(t, groups[len(groups)-1]) + managed["hooks"] = append( + requireArray(t, managed["hooks"]), + map[string]any{"type": "command", "command": "user-command", "timeout": float64(10)}, ) - 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 { + if err := fixture.plugin.Uninstall(grokTestAgentID); 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 { + if _, exists := remainingHooks["PostToolUse"]; exists { + t.Fatalf("managed success hook remains: %#v", remainingHooks) + } + if _, exists := remainingHooks["PostToolUseFailure"]; exists { + t.Fatalf("managed failure hook remains: %#v", remainingHooks) + } + commands := []string{} + for _, groupValue := range requireArray(t, remainingHooks["PreToolUse"]) { + for _, handlerValue := range requireArray(t, requireObject(t, groupValue)["hooks"]) { + commands = append(commands, requireObject(t, handlerValue)["command"].(string)) + } + } + if !reflect.DeepEqual(commands, []string{lookalike, "user-command"}) { + t.Fatalf("remaining commands = %#v", commands) + } +} + +func TestGrokUninstallPreservesUserConfigAndRemovesOwnedConfig(t *testing.T) { + userSource := `{"owner":"user","hooks":{"Notification":[]}}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &userSource}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install user Grok hooks: %v", err) + } + if err := fixture.plugin.Uninstall(grokTestAgentID); err != nil { + t.Fatalf("uninstall user Grok hooks: %v", err) + } + remaining := readGrokTestObject(t, fixture.configPath) + if remaining["owner"] != "user" || !reflect.DeepEqual( + requireObject(t, remaining["hooks"])["Notification"], + []any{}, + ) { 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) + owned := prepareGrokFixture(t, grokFixtureOptions{}) + if err := owned.plugin.Install(owned.config); err != nil { + t.Fatalf("install owned 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) + if err := owned.plugin.Uninstall(grokTestAgentID); err != nil { + t.Fatalf("uninstall owned Grok hooks: %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) { + if _, err := os.Lstat(owned.configPath); !os.IsNotExist(err) { t.Fatalf("owned Grok config remains: %v", err) } } -func TestGrokMalformedConfigAndShapesPreventRuntimeWrites(t *testing.T) { +func TestGrokStatusRequiresExactTripleIdentityAndRuntimeFiles(t *testing.T) { + fixture := prepareGrokFixture(t, grokFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + assertInstalled := func(want bool) { + t.Helper() + status, err := fixture.plugin.Status() + if err != nil || status.Installed != want { + t.Fatalf("Grok status = %#v, %v; want installed %v", status, err, want) + } + } + assertInstalled(true) + settings := readGrokTestObject(t, fixture.configPath) + hooks := requireObject(t, settings["hooks"]) + delete(hooks, "PostToolUseFailure") + writeGrokTestObject(t, fixture.configPath, settings) + assertInstalled(false) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Grok hooks: %v", err) + } + settings = readGrokTestObject(t, fixture.configPath) + hooks = requireObject(t, settings["hooks"]) + hooks["PostToolUseFailure"] = append( + requireArray(t, hooks["PostToolUseFailure"]), + requireArray(t, hooks["PostToolUseFailure"])[0], + ) + writeGrokTestObject(t, fixture.configPath, settings) + assertInstalled(false) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair duplicate Grok hooks: %v", err) + } + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard runtime: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured || + status.HookScriptExists { + t.Fatalf("missing guard status = %#v, %v", status, err) + } +} + +func TestGrokRejectsInvalidHookShapesBeforeRuntimeWrites(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"}, + {"malformed", "{ malformed", "parse Grok user hooks"}, + {"comments", "{\"hooks\":{} // comment\n}", "invalid character"}, + {"trailing comma", `{"hooks":{},}`, "invalid character"}, + {"non-finite timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":NaN}]}]}}`, "invalid character"}, + {"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate"}, + {"hooks", `{"hooks":null}`, `field "hooks" must be an object`}, + {"event", `{"hooks":{"PreToolUse":null}}`, "must be an array"}, + {"group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"}, + {"matcher", `{"hooks":{"PreToolUse":[{"matcher":1,"hooks":[]}]}}`, "matcher must be a string"}, + {"lifecycle matcher", `{"hooks":{"SessionStart":[{"matcher":"x","hooks":[]}]}}`, "cannot declare a matcher"}, + {"handler", `{"hooks":{"PreToolUse":[{"hooks":[null]}]}}`, "must be an object"}, + {"type", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"file"}]}]}}`, "unsupported type"}, + {"command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "non-empty command"}, + {"http", `{"hooks":{"PostToolUse":[{"hooks":[{"type":"http","url":""}]}]}}`, "non-empty url"}, + {"negative timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":-1}]}]}}`, "non-negative integer"}, + {"fraction timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":1.5}]}]}}`, "non-negative integer"}, + {"unsafe timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":9007199254740992}]}]}}`, "non-negative integer"}, + {"environment", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","env":{"A":1}}]}]}}`, "env must map names to strings"}, } { t.Run(testCase.name, func(t *testing.T) { - fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &testCase.raw}) + fixture := prepareGrokFixture( + t, + grokFixtureOptions{existingRaw: grokString(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) @@ -316,53 +282,67 @@ func TestGrokMalformedConfigAndShapesPreventRuntimeWrites(t *testing.T) { 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) - } - } + assertNoGrokRuntimeWrites(t, fixture) }) } } -func TestGrokMissingGuardPreventsWrites(t *testing.T) { - fixture := prepareGrokFixture(t, grokFixtureOptions{withoutGuard: true}) +func TestGrokRejectsRenderedConfigBeyondManagedSourceLimit(t *testing.T) { + prefix := `{"padding":"` + suffix := `"}` + padding := strings.Repeat( + "a", + maxManagedSourceBytes-len(prefix)-len(suffix), + ) + raw := prefix + padding + suffix + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &raw}) err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { + if err == nil || !strings.Contains(err.Error(), "exceeds") { 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) - } + current, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(current) != raw { + t.Fatalf("large source changed: size=%d, err=%v", len(current), readErr) + } + assertNoGrokRuntimeWrites(t, fixture) +} + +func TestGrokAcceptsOfficialZeroTimeoutAndStringEnvironment(t *testing.T) { + raw := `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"user","timeout":0,"env":{"A":"B"}}]}]}}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &raw}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks with official fields: %v", err) + } + hooks := requireObject(t, readGrokTestObject(t, fixture.configPath)["hooks"]) + user := requireObject(t, requireArray(t, requireObject( + t, + requireArray(t, hooks["PreToolUse"])[0], + )["hooks"])[0]) + if user["timeout"] != float64(0) { + t.Fatalf("zero timeout changed: %#v", user) } } -func TestGrokStatusSurfacesMalformedRuntimeMetadata(t *testing.T) { +func TestGrokRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { fixture := prepareGrokFixture(t, grokFixtureOptions{}) + fixture.config.Token = "" if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install Grok hooks: %v", err) + t.Fatalf("install Grok hooks without token: %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) + config := readGrokTestObject(t, fixture.runtimeConfig) + if _, exists := config["token"]; exists { + t.Fatalf("empty optional token was persisted: %#v", config) } } -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()) +func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) { + if runtime.GOOS == "windows" { + if !sameGrokAgentID("AGENT-1", "agent-1") { + t.Fatal("Windows Grok agent IDs should compare case-insensitively") } + return + } + if sameGrokAgentID("AGENT-1", "agent-1") { + t.Fatal("POSIX Grok agent IDs should preserve case") } } diff --git a/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go index f0348f7..b611d34 100644 --- a/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/grok_test_helpers_test.go @@ -7,23 +7,27 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "runtime" "strings" "testing" ) -const grokTestAgentID = "agent-1" +const ( + grokTestAgentID = "agent-1" + grokPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" +) type grokFixtureOptions struct { existingRaw *string useDefaultHome bool - withoutGuard bool } type grokFixture struct { plugin *GrokPlugin config InstallConfig homeDir string + projectDir string grokHome string agentDir string configPath string @@ -39,25 +43,20 @@ func grokString(value string) *string { func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture { t.Helper() - homeDir := filepath.Join(t.TempDir(), "home with spaces") + root := t.TempDir() + homeDir := filepath.Join(root, "home with 'quote %ELYDORA_HOOK_PATH%") + projectDir := filepath.Join(root, "project 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 err := os.MkdirAll(projectDir, 0700); err != nil { + t.Fatalf("create project directory: %v", err) } if options.existingRaw != nil { - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(configPath), 0700); err != nil { t.Fatalf("create Grok config directory: %v", err) } if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil { @@ -66,27 +65,21 @@ func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture { } t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) + t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-command-fragment")) if options.useDefaultHome { t.Setenv("GROK_HOME", "") } else { t.Setenv("GROK_HOME", grokHome) } + guardPath := filepath.Join(agentDir, grokGuardScript) 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, + plugin: &GrokPlugin{}, config: InstallConfig{ + AgentName: grokAgentKey, OrgID: "org-1", AgentID: grokTestAgentID, + PrivateKey: grokPrivateKey, KID: "kid-1", Token: "token-1", + BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, }, - configPath: configPath, - guardPath: guardPath, + homeDir: homeDir, projectDir: projectDir, grokHome: grokHome, + agentDir: agentDir, configPath: configPath, guardPath: guardPath, hookPath: filepath.Join(agentDir, grokAuditScript), runtimeConfig: filepath.Join(agentDir, "config.json"), privateKey: filepath.Join(agentDir, "private.key"), @@ -106,18 +99,23 @@ func readGrokTestObject(t *testing.T, path string) map[string]any { return value } -func grokTestManagedHandler(t *testing.T, settings map[string]any, event, scriptName string) map[string]any { +func grokTestManagedHandler( + t *testing.T, + settings map[string]any, + event string, + 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) { + reference, err := managedGrokReference(handler, scriptName) + if err != nil { + t.Fatalf("inspect managed %s handler: %v", event, err) + } + if reference != nil { return handler } } @@ -126,32 +124,136 @@ func grokTestManagedHandler(t *testing.T, settings map[string]any, event, script return nil } -func runGrokCommand(t *testing.T, command, homeDir, payload string) (int, string) { +func requireStrictGrokTriple(t *testing.T, settings map[string]any) { + t.Helper() + hooks := requireObject(t, settings["hooks"]) + commands := map[string]string{} + for _, item := range []struct{ event, script string }{ + {"PreToolUse", grokGuardScript}, + {"PostToolUse", grokAuditScript}, + {"PostToolUseFailure", grokAuditScript}, + } { + groups := requireArray(t, hooks[item.event]) + handler := grokTestManagedHandler(t, settings, item.event, item.script) + wantHandler := map[string]any{ + "type": "command", "command": handler["command"], "timeout": float64(10), + } + if !reflect.DeepEqual(handler, wantHandler) { + t.Fatalf("%s contract = %#v", item.event, groups) + } + managedGroups := 0 + for _, groupValue := range groups { + group := requireObject(t, groupValue) + for _, handlerValue := range requireArray(t, group["hooks"]) { + candidate := requireObject(t, handlerValue) + reference, err := managedGrokReference(candidate, item.script) + if err != nil { + t.Fatalf("inspect %s contract: %v", item.event, err) + } + if reference != nil { + managedGroups++ + if len(group) != 1 || len(requireArray(t, group["hooks"])) != 1 { + t.Fatalf("%s managed group has extra fields: %#v", item.event, group) + } + } + } + } + if managedGroups != 1 { + t.Fatalf("%s managed group count = %d", item.event, managedGroups) + } + command := handler["command"].(string) + if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") { + t.Fatalf("Windows Grok command is not encoded PowerShell: %q", command) + } + commands[item.event] = command + } + if commands["PostToolUse"] != commands["PostToolUseFailure"] { + t.Fatal("Grok success and failure audit commands differ") + } +} + +func grokOfficialPayload(event string) map[string]any { + nativeName := map[string]string{ + "PreToolUse": "pre_tool_use", "PostToolUse": "post_tool_use", + "PostToolUseFailure": "post_tool_use_failure", + }[event] + payload := map[string]any{ + "hookEventName": nativeName, + "sessionId": "session-1", + "cwd": "C:/project", + "workspaceRoot": "C:/project", + "toolName": "Bash", + "toolInput": map[string]any{"command": "echo test"}, + "toolUseId": "call-1", + "toolInputTruncated": false, + "timestamp": "2026-07-19T12:00:00.000Z", + } + if event != "PreToolUse" { + payload["toolResult"] = map[string]any{ + "output": "test", "success": event == "PostToolUse", + } + payload["toolResultTruncated"] = false + payload["durationMs"] = float64(12) + } + return payload +} + +func runGrokCommand( + t *testing.T, + command string, + fixture *grokFixture, + payload map[string]any, +) (int, string, string) { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Grok payload: %v", err) + } + return runGrokRawCommand(t, command, fixture, encoded) +} + +func runGrokRawCommand( + t *testing.T, + command string, + fixture *grokFixture, + encoded []byte, +) (int, string, 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 { + 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.Dir = fixture.projectDir + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "ELYDORA_HOOK_PATH=injected-command-fragment", + ) + process.Stdin = bytes.NewReader(encoded) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout process.Stderr = &stderr err := process.Run() if err == nil { - return 0, stderr.String() + return 0, stdout.String(), stderr.String() } var exitError *exec.ExitError if errors.As(err, &exitError) { - return exitError.ExitCode(), stderr.String() + return exitError.ExitCode(), stdout.String(), stderr.String() } t.Fatalf("run Grok hook command: %v", err) - return -1, stderr.String() + return -1, stdout.String(), stderr.String() } func writeGrokTestObject(t *testing.T, path string, value map[string]any) { @@ -164,3 +266,54 @@ func writeGrokTestObject(t *testing.T, path string, value map[string]any) { t.Fatalf("write Grok config: %v", err) } } + +func legacyGrokCommand(t *testing.T, scriptPath string) string { + t.Helper() + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + if runtime.GOOS == "windows" { + return quoteGrokLegacyWindowsArgument(nodePath) + " " + + quoteGrokLegacyWindowsArgument(scriptPath) + } + return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) +} + +func assertNoGrokRuntimeWrites(t *testing.T, fixture *grokFixture) { + t.Helper() + for _, path := range []string{ + fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath, + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime file exists at %s: %v", path, err) + } + } +} + +func assertNoGrokTransactionArtifacts(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return + } + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || + strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk Grok fixture: %v", err) + } +} + +func grokSymlinkOrSkip(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/grok_transaction_test.go b/sdks/go/cmd/elydora/plugins/grok_transaction_test.go new file mode 100644 index 0000000..7c600fa --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/grok_transaction_test.go @@ -0,0 +1,197 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func snapshotGrokFiles(t *testing.T, paths ...string) map[string][]byte { + t.Helper() + result := make(map[string][]byte, len(paths)) + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read snapshot %s: %v", path, err) + } + result[path] = raw + } + return result +} + +func requireGrokSnapshot(t *testing.T, snapshot map[string][]byte) { + t.Helper() + for path, want := range snapshot { + actual, err := os.ReadFile(path) + if err != nil || string(actual) != string(want) { + t.Fatalf("snapshot changed at %s: %q, %v", path, actual, err) + } + } +} + +func TestGrokInstallRollsBackFourRuntimesAfterConfigCommitFailure(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) + settings["owner"] = "user" + writeGrokTestObject(t, fixture.configPath, settings) + staleRuntime := `{"org_id":"old","agent_id":"agent-1","kid":"old","base_url":"https://old.test","agent_name":"grok"}` + for _, item := range []struct{ path, source string }{ + {fixture.guardPath, "stale guard\n"}, + {fixture.runtimeConfig, staleRuntime}, + {fixture.privateKey, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, + {fixture.hookPath, "stale audit\n"}, + } { + if err := os.WriteFile(item.path, []byte(item.source), 0600); err != nil { + t.Fatalf("write stale runtime %s: %v", item.path, err) + } + } + paths := []string{ + fixture.guardPath, + fixture.runtimeConfig, + fixture.privateKey, + fixture.hookPath, + fixture.configPath, + } + before := snapshotGrokFiles(t, paths...) + fixture.plugin.rename = func(source, destination string) error { + if sameGrokPath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Grok config failure") + } + return os.Rename(source, destination) + } + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected Grok config failure") { + t.Fatalf("install error = %v", err) + } + requireGrokSnapshot(t, before) + assertNoGrokTransactionArtifacts(t, fixture.homeDir) +} + +func TestPreparedGrokInstallRejectsConcurrentConfigChange(t *testing.T) { + source := `{"owner":"original"}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &source}) + document, err := readGrokDocument() + if err != nil { + t.Fatalf("read Grok document: %v", err) + } + paths, nodePath, err := preflightGrokInstallation(fixture.config, document) + if err != nil { + t.Fatalf("preflight Grok installation: %v", err) + } + guardCommand, err := buildGrokCommand(nodePath, paths.guardPath) + if err != nil { + t.Fatalf("build guard command: %v", err) + } + auditCommand, err := buildGrokCommand(nodePath, paths.auditPath) + if err != nil { + t.Fatalf("build audit command: %v", err) + } + hooks, err := removeManagedGrokHooks(document.hooks, "") + if err != nil { + t.Fatalf("remove managed Grok hooks: %v", err) + } + for _, item := range []struct{ event, command string }{ + {"PreToolUse", guardCommand}, + {"PostToolUse", auditCommand}, + {"PostToolUseFailure", auditCommand}, + } { + hooks[item.event] = append(hooks[item.event], buildGrokGroup(item.command)) + } + rendered, err := renderGrokDocument(document, hooks) + if err != nil { + t.Fatalf("render Grok document: %v", err) + } + changes, err := prepareGrokInstallationChanges(fixture.config, paths, rendered) + if err != nil { + t.Fatalf("prepare Grok installation: %v", err) + } + concurrent := []byte(`{"owner":"concurrent"}`) + if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { + t.Fatalf("write concurrent config: %v", err) + } + err = writeGrokChanges( + changes, + "Install Grok hooks", + nil, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.configPath), + ) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("prepared install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != string(concurrent) { + t.Fatalf("concurrent config changed: %q, %v", actual, readErr) + } + assertNoGrokRuntimeWrites(t, fixture) + assertNoGrokTransactionArtifacts(t, fixture.homeDir) +} + +func TestGrokInstallDetectsConcurrentConfigIdentityReplacement(t *testing.T) { + source := []byte(`{"owner":"original"}`) + fixture := prepareGrokFixture( + t, + grokFixtureOptions{existingRaw: grokString(string(source))}, + ) + mutated := false + fixture.plugin.rename = func(stagedPath, destination string) error { + if !mutated && strings.HasSuffix(stagedPath, ".tmp") { + mutated = true + external := fixture.configPath + ".external" + if err := os.Rename(fixture.configPath, external); err != nil { + return err + } + if err := os.WriteFile(fixture.configPath, source, 0600); err != nil { + return err + } + } + return os.Rename(stagedPath, destination) + } + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("concurrent identity error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != string(source) { + t.Fatalf("replacement config changed: %q, %v", actual, readErr) + } + assertNoGrokRuntimeWrites(t, fixture) + assertNoGrokTransactionArtifacts(t, fixture.homeDir) +} + +func TestGrokUninstallPreservesConfigAfterCommitFailure(t *testing.T) { + source := `{"owner":"user"}` + fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &source}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Grok hooks: %v", err) + } + before, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read installed Grok config: %v", err) + } + fixture.plugin.rename = func(source, destination string) error { + if sameGrokPath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Grok uninstall failure") + } + return os.Rename(source, destination) + } + err = fixture.plugin.Uninstall(grokTestAgentID) + if err == nil || !strings.Contains(err.Error(), "injected Grok uninstall failure") { + t.Fatalf("uninstall error = %v", err) + } + after, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(after) != string(before) { + t.Fatalf("Grok config changed: %q, %v", after, readErr) + } + assertNoGrokTransactionArtifacts(t, fixture.homeDir) +} diff --git a/sdks/go/cmd/elydora/plugins/guardtemplate.go b/sdks/go/cmd/elydora/plugins/guardtemplate.go index 413af58..9bc9349 100644 --- a/sdks/go/cmd/elydora/plugins/guardtemplate.go +++ b/sdks/go/cmd/elydora/plugins/guardtemplate.go @@ -4,10 +4,14 @@ import "fmt" // GenerateGuardScript returns a self-contained agent status guard. func GenerateGuardScript(agentName string, agentID string) string { - return generateGuardScript(agentName, agentID, "", false) + return generateGuardScript(agentName, agentID, "", false, "") } -func generateGuardScript(agentName, agentID, successOutput string, failClosed bool) string { +func generateGuardScript( + agentName, agentID, successOutput string, + failClosed bool, + denyProtocol string, +) string { return fmt.Sprintf(`#!/usr/bin/env node 'use strict'; @@ -21,6 +25,7 @@ const AGENT_NAME = %q; const AGENT_ID = %q; const SUCCESS_OUTPUT = %q; const FAIL_CLOSED = %t; +const DENY_PROTOCOL = %q; const ELYDORA_DIR = path.join(os.homedir(), '.elydora'); const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json'); const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.json'); @@ -38,12 +43,17 @@ function validateStatus(value, source) { function blockAgent(status) { const state = status === 'frozen' ? 'is frozen by Elydora' : 'is revoked in Elydora'; const message = 'Agent "' + AGENT_NAME + '" ' + state + '.'; - if (SUCCESS_OUTPUT) { + if (DENY_PROTOCOL === 'cursor') { process.stdout.write(JSON.stringify({ permission: 'deny', userMessage: message, agentMessage: message + ' Tool execution is blocked.', }) + '\n'); + } else if (DENY_PROTOCOL === 'grok') { + process.stdout.write(JSON.stringify({ + decision: 'deny', + reason: message, + }) + '\n'); } process.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\n'); process.exitCode = 2; @@ -221,5 +231,13 @@ main().then(() => { ); process.exitCode = 1; }); -`, agentName, agentName, agentID, successOutput, failClosed, protectedRuntimeFileReader) +`, + agentName, + agentName, + agentID, + successOutput, + failClosed, + denyProtocol, + protectedRuntimeFileReader, + ) } diff --git a/sdks/go/cmd/elydora/plugins/kimi_installation.go b/sdks/go/cmd/elydora/plugins/kimi_installation.go index c449e3a..0d6cbd6 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_installation.go +++ b/sdks/go/cmd/elydora/plugins/kimi_installation.go @@ -186,7 +186,7 @@ func prepareKimiInstallationChanges( }{ { paths.guardPath, "Elydora guard runtime", - []byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false)), 0700, + []byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false, "")), 0700, }, {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, diff --git a/sdks/go/cmd/elydora/plugins/transaction.go b/sdks/go/cmd/elydora/plugins/transaction.go index 1f3c5b1..ee52147 100644 --- a/sdks/go/cmd/elydora/plugins/transaction.go +++ b/sdks/go/cmd/elydora/plugins/transaction.go @@ -63,6 +63,14 @@ func prepareSourceChange( if !existed { original = nil } + if !remove && int64(len(next)) > maxManagedSourceBytes { + return nil, fmt.Errorf( + "%s exceeds %d bytes: %s", + label, + maxManagedSourceBytes, + filePath, + ) + } snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes) if err != nil { return nil, err