fix(go): mirror official Grok hooks
This commit is contained in:
1 parent
ed36743d77
commit
7fe99f7e5f
18 files changed
+2229
-630
No files matched your search
+1
-1
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func prepareCodexInstallationChanges(
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
paths.guardPath, "Elydora guard runtime",
|
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.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ func prepareCursorInstallationChanges(
|
|||||||
config.AgentID,
|
config.AgentID,
|
||||||
`{"permission":"allow"}`+"\n",
|
`{"permission":"allow"}`+"\n",
|
||||||
true,
|
true,
|
||||||
|
"cursor",
|
||||||
)), 0700,
|
)), 0700,
|
||||||
},
|
},
|
||||||
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
{filepath.Join(agentDirectory, "config.json"), "Elydora runtime config", runtimeConfig, 0600},
|
||||||
|
|||||||
@@ -1,110 +1,133 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
// GrokPlugin manages Grok Build native global user hooks.
|
// GrokPlugin manages Grok global user hooks.
|
||||||
type GrokPlugin struct{}
|
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 {
|
func (p *GrokPlugin) Install(config InstallConfig) error {
|
||||||
if config.AgentID == "" {
|
document, err := readGrokDocument()
|
||||||
return fmt.Errorf("agent ID is required")
|
|
||||||
}
|
|
||||||
document, err := readGrokConfig()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if config.GuardScriptPath == "" {
|
paths, nodePath, err := preflightGrokInstallation(config, document)
|
||||||
return fmt.Errorf("guard script path is required")
|
|
||||||
}
|
|
||||||
guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !guardExists {
|
guardCommand, err := buildGrokCommand(nodePath, paths.guardPath)
|
||||||
return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath)
|
|
||||||
}
|
|
||||||
hookPath, err := hookScriptPath(config.AgentID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if config.HookScript != "" {
|
auditCommand, err := buildGrokCommand(nodePath, paths.auditPath)
|
||||||
hookPath = config.HookScript
|
|
||||||
}
|
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := grokRuntimeRoot()
|
hooks, err := removeManagedGrokHooks(document.hooks, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hooks, _ := removeManagedGrokHooks(document.hooks, "", runtimeRoot)
|
for _, item := range []struct{ event, command string }{
|
||||||
hooks["PreToolUse"] = append(hooks["PreToolUse"], buildGrokGroup(
|
{"PreToolUse", guardCommand},
|
||||||
buildGrokHandler(buildGrokCommand(nodePath, config.GuardScriptPath)),
|
{"PostToolUse", auditCommand},
|
||||||
))
|
{"PostToolUseFailure", auditCommand},
|
||||||
hooks["PostToolUse"] = append(hooks["PostToolUse"], buildGrokGroup(
|
} {
|
||||||
buildGrokHandler(buildGrokCommand(nodePath, hookPath)),
|
hooks[item.event] = append(hooks[item.event], buildGrokGroup(item.command))
|
||||||
))
|
|
||||||
next := cloneGrokObject(document.root)
|
|
||||||
next["hooks"] = renderGrokHooks(hooks)
|
|
||||||
|
|
||||||
runtimeConfig := config
|
|
||||||
runtimeConfig.AgentName = grokAgentKey
|
|
||||||
if err := GenerateHookScript(hookPath, runtimeConfig); err != nil {
|
|
||||||
return fmt.Errorf("generate hook script: %w", err)
|
|
||||||
}
|
}
|
||||||
if err := writeHookJSONObjectAtomic(document.configPath, next); err != nil {
|
rendered, err := renderGrokDocument(document, hooks)
|
||||||
return fmt.Errorf("write Grok hooks config: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *GrokPlugin) Uninstall(agentID string) error {
|
func (p *GrokPlugin) Uninstall(agentID string) error {
|
||||||
document, err := readGrokConfig()
|
document, err := readGrokDocument()
|
||||||
if err != nil || !document.exists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := grokRuntimeRoot()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hooks, changed := removeManagedGrokHooks(document.hooks, agentID, runtimeRoot)
|
hooks, err := removeManagedGrokHooks(document.hooks, agentID)
|
||||||
if !changed {
|
if err != nil {
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
next := cloneGrokObject(document.root)
|
rendered, err := renderGrokDocument(document, hooks)
|
||||||
if len(hooks) == 0 {
|
if err != nil {
|
||||||
delete(next, "hooks")
|
return fmt.Errorf("render Grok user hooks: %w", err)
|
||||||
} else {
|
|
||||||
next["hooks"] = renderGrokHooks(hooks)
|
|
||||||
}
|
}
|
||||||
if len(next) == 0 {
|
change, err := prepareRenderedGrokChange(rendered)
|
||||||
return removeHookFile(document.configPath, "Grok hooks config")
|
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) {
|
func (p *GrokPlugin) Status() (PluginStatus, error) {
|
||||||
document, err := readGrokConfig()
|
configPath, pathErr := grokConfigPath()
|
||||||
|
entry := SupportedAgents[grokAgentKey]
|
||||||
status := PluginStatus{
|
status := PluginStatus{
|
||||||
AgentName: grokAgentKey,
|
AgentName: grokAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
|
||||||
DisplayName: "Grok Build",
|
|
||||||
ConfigPath: document.configPath,
|
|
||||||
}
|
}
|
||||||
|
if pathErr != nil {
|
||||||
|
return status, pathErr
|
||||||
|
}
|
||||||
|
document, err := readGrokDocument()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := grokRuntimeRoot()
|
contracts, err := grokRuntimeContracts(document.hooks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
contracts := grokRuntimeContracts(document.hooks, runtimeRoot)
|
status.HookConfigured = len(contracts) > 0
|
||||||
if len(contracts) == 0 {
|
if !status.HookConfigured {
|
||||||
return status, nil
|
return status, nil
|
||||||
}
|
}
|
||||||
status.HookConfigured = true
|
status.HookScriptExists, err = grokRuntimeFilesExist(contracts)
|
||||||
status.HookScriptExists, err = grokRuntimeScriptsExist(contracts, runtimeRoot)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return status, err
|
return status, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"path/filepath"
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +17,13 @@ const (
|
|||||||
grokAuditScript = "hook.js"
|
grokAuditScript = "hook.js"
|
||||||
grokHookTimeout = float64(10)
|
grokHookTimeout = float64(10)
|
||||||
grokConfigFile = "elydora-audit.json"
|
grokConfigFile = "elydora-audit.json"
|
||||||
grokPOSIXApostrophe = `'"'"'`
|
)
|
||||||
|
|
||||||
|
var grokMatcherRejectingEvents = stringSet(
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"Stop",
|
||||||
|
"UserPromptSubmit",
|
||||||
)
|
)
|
||||||
|
|
||||||
type grokGroup struct {
|
type grokGroup struct {
|
||||||
@@ -29,6 +38,14 @@ type grokDocument struct {
|
|||||||
configPath string
|
configPath string
|
||||||
root map[string]any
|
root map[string]any
|
||||||
hooks grokHooks
|
hooks grokHooks
|
||||||
|
raw []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type grokRenderedDocument struct {
|
||||||
|
document *grokDocument
|
||||||
|
changed bool
|
||||||
|
next []byte
|
||||||
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type grokRuntimeContract struct {
|
type grokRuntimeContract struct {
|
||||||
@@ -45,15 +62,37 @@ func cloneGrokObject(value map[string]any) map[string]any {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGrokHandler(value any, event string, groupIndex, handlerIndex int) (map[string]any, error) {
|
func cloneGrokHooks(source grokHooks) grokHooks {
|
||||||
label := fmt.Sprintf("Grok hooks config handler hooks.%s[%d].hooks[%d]", event, groupIndex, handlerIndex)
|
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)
|
handler, ok := value.(map[string]any)
|
||||||
if !ok || handler == nil {
|
if !ok || handler == nil {
|
||||||
return nil, fmt.Errorf("%s must be an object", label)
|
return nil, fmt.Errorf("%s must be an object", label)
|
||||||
}
|
}
|
||||||
handlerType, ok := handler["type"].(string)
|
handlerType, ok := handler["type"].(string)
|
||||||
if !ok || (handlerType != "command" && handlerType != "http") {
|
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" {
|
if handlerType == "command" {
|
||||||
command, ok := handler["command"].(string)
|
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)
|
return nil, fmt.Errorf("%s requires a non-empty url", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if timeoutValue, exists := handler["timeout"]; exists {
|
if value, exists := handler["timeout"]; exists {
|
||||||
timeout, ok := timeoutValue.(float64)
|
timeout, ok := value.(float64)
|
||||||
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 {
|
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) ||
|
||||||
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
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
|
return cloneGrokObject(handler), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, error) {
|
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)
|
object, ok := value.(map[string]any)
|
||||||
if !ok || object == nil {
|
if !ok || object == nil {
|
||||||
return grokGroup{}, fmt.Errorf("%s must be an object", label)
|
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 {
|
if _, ok := matcher.(string); !ok {
|
||||||
return grokGroup{}, fmt.Errorf("%s matcher must be a string", label)
|
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)
|
values, ok := object["hooks"].([]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -93,7 +155,12 @@ func validateGrokGroup(value any, event string, groupIndex int) (grokGroup, erro
|
|||||||
}
|
}
|
||||||
handlers := make([]map[string]any, 0, len(values))
|
handlers := make([]map[string]any, 0, len(values))
|
||||||
for handlerIndex, value := range values {
|
for handlerIndex, value := range values {
|
||||||
handler, err := validateGrokHandler(value, event, groupIndex, handlerIndex)
|
handler, err := validateGrokHandler(
|
||||||
|
value,
|
||||||
|
event,
|
||||||
|
groupIndex,
|
||||||
|
handlerIndex,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return grokGroup{}, err
|
return grokGroup{}, err
|
||||||
}
|
}
|
||||||
@@ -109,13 +176,16 @@ func readGrokHooks(root map[string]any) (grokHooks, error) {
|
|||||||
}
|
}
|
||||||
object, ok := value.(map[string]any)
|
object, ok := value.(map[string]any)
|
||||||
if !ok || object == nil {
|
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))
|
hooks := make(grokHooks, len(object))
|
||||||
for event, groupValue := range object {
|
for event, groupValue := range object {
|
||||||
values, ok := groupValue.([]any)
|
values, ok := groupValue.([]any)
|
||||||
if !ok {
|
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))
|
groups := make([]grokGroup, 0, len(values))
|
||||||
for groupIndex, value := range values {
|
for groupIndex, value := range values {
|
||||||
@@ -130,6 +200,30 @@ func readGrokHooks(root map[string]any) (grokHooks, error) {
|
|||||||
return hooks, nil
|
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 {
|
func renderGrokHooks(hooks grokHooks) map[string]any {
|
||||||
result := make(map[string]any, len(hooks))
|
result := make(map[string]any, len(hooks))
|
||||||
for event, groups := range hooks {
|
for event, groups := range hooks {
|
||||||
@@ -148,158 +242,236 @@ func renderGrokHooks(hooks grokHooks) map[string]any {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func readGrokWindowsArgument(command string, start int) (string, int, bool) {
|
func buildGrokHandler(command string) map[string]any {
|
||||||
if start >= len(command) || command[start] != '"' {
|
return map[string]any{
|
||||||
return "", start, false
|
"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) {
|
func buildGrokGroup(command string) grokGroup {
|
||||||
if start >= len(command) || command[start] != '\'' {
|
return grokGroup{
|
||||||
return "", start, false
|
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) {
|
func exactManagedGrokGroup(group grokGroup) bool {
|
||||||
reader := readGrokPOSIXArgument
|
_, hasHooks := group.object["hooks"]
|
||||||
if runtime.GOOS == "windows" {
|
return len(group.object) == 1 && hasHooks
|
||||||
reader = readGrokWindowsArgument
|
|
||||||
}
|
|
||||||
executable, next, ok := reader(command, 0)
|
|
||||||
if !ok || next >= len(command) || command[next] != ' ' {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
script, end, ok := reader(command, next+1)
|
|
||||||
if !ok || end != len(command) || executable == "" || script == "" {
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
return executable, script, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeGrokPath(value string) string {
|
func managedGrokReference(
|
||||||
absolute, err := filepath.Abs(value)
|
handler map[string]any,
|
||||||
if err == nil {
|
scriptName string,
|
||||||
value = absolute
|
) (*grokRuntimeReference, error) {
|
||||||
}
|
if len(handler) != 3 || handler["type"] != "command" ||
|
||||||
value = filepath.ToSlash(filepath.Clean(value))
|
handler["timeout"] != grokHookTimeout {
|
||||||
if runtime.GOOS == "windows" {
|
return nil, nil
|
||||||
return strings.ToLower(value)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokPath(left, right string) bool {
|
|
||||||
return normalizeGrokPath(left) == normalizeGrokPath(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameGrokFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedGrokAgentID(handler map[string]any, scriptName, runtimeRoot string) (string, bool) {
|
|
||||||
if handler["type"] != "command" || handler["timeout"] != grokHookTimeout {
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
command, ok := handler["command"].(string)
|
command, ok := handler["command"].(string)
|
||||||
if !ok {
|
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
|
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(
|
func removeManagedGrokGroups(
|
||||||
groups []grokGroup,
|
groups []grokGroup,
|
||||||
scriptName, agentID, runtimeRoot string,
|
scriptName string,
|
||||||
) ([]grokGroup, bool) {
|
agentID string,
|
||||||
|
) ([]grokGroup, error) {
|
||||||
result := make([]grokGroup, 0, len(groups))
|
result := make([]grokGroup, 0, len(groups))
|
||||||
changed := false
|
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if _, hasMatcher := group.object["matcher"]; hasMatcher {
|
if !exactManagedGrokGroup(group) {
|
||||||
result = append(result, group)
|
result = append(result, group)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
handlers := make([]map[string]any, 0, len(group.handlers))
|
kept := make([]map[string]any, 0, len(group.handlers))
|
||||||
groupChanged := false
|
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
managedID, managed := managedGrokAgentID(handler, scriptName, runtimeRoot)
|
reference, err := managedGrokReference(handler, scriptName)
|
||||||
remove := managed && (agentID == "" || sameGrokAgentID(managedID, agentID))
|
if err != nil {
|
||||||
if remove {
|
return nil, err
|
||||||
changed = true
|
|
||||||
groupChanged = true
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
handlers = append(handlers, handler)
|
remove := reference != nil &&
|
||||||
}
|
(agentID == "" || sameGrokAgentID(reference.agentID, agentID))
|
||||||
if len(handlers) > 0 || !groupChanged {
|
if !remove {
|
||||||
result = append(result, grokGroup{object: group.object, handlers: handlers})
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, changed
|
if len(kept) > 0 {
|
||||||
|
result = append(result, grokGroup{
|
||||||
|
object: map[string]any{"hooks": group.object["hooks"]},
|
||||||
|
handlers: kept,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeManagedGrokHooks(hooks grokHooks, agentID, runtimeRoot string) (grokHooks, bool) {
|
func removeManagedGrokHooks(hooks grokHooks, agentID string) (grokHooks, error) {
|
||||||
result := make(grokHooks, len(hooks))
|
result := cloneGrokHooks(hooks)
|
||||||
for event, groups := range hooks {
|
|
||||||
result[event] = groups
|
|
||||||
}
|
|
||||||
changed := false
|
|
||||||
for _, contract := range []struct{ event, script string }{
|
for _, contract := range []struct{ event, script string }{
|
||||||
{"PreToolUse", grokGuardScript},
|
{"PreToolUse", grokGuardScript},
|
||||||
{"PostToolUse", grokAuditScript},
|
{"PostToolUse", grokAuditScript},
|
||||||
|
{"PostToolUseFailure", grokAuditScript},
|
||||||
} {
|
} {
|
||||||
groups, eventChanged := removeManagedGrokGroups(
|
groups, err := removeManagedGrokGroups(
|
||||||
result[contract.event], contract.script, agentID, runtimeRoot,
|
result[contract.event],
|
||||||
|
contract.script,
|
||||||
|
agentID,
|
||||||
)
|
)
|
||||||
if !eventChanged {
|
if err != nil {
|
||||||
continue
|
return nil, err
|
||||||
}
|
}
|
||||||
changed = true
|
|
||||||
if len(groups) == 0 {
|
if len(groups) == 0 {
|
||||||
delete(result, contract.event)
|
delete(result, contract.event)
|
||||||
} else {
|
} else {
|
||||||
result[contract.event] = groups
|
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
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -4,178 +4,113 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func grokHomeDirectory() (string, error) {
|
func grokHomePath() (string, error) {
|
||||||
|
configured := os.Getenv("GROK_HOME")
|
||||||
|
if configured == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve home directory: %w", err)
|
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 GROK_HOME at %s: %w", configured, err)
|
||||||
|
}
|
||||||
return home, nil
|
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) {
|
func grokConfigPath() (string, error) {
|
||||||
home, err := grokHomeDirectory()
|
home, err := grokHomePath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
grokHome := os.Getenv("GROK_HOME")
|
return filepath.Join(home, "hooks", grokConfigFile), nil
|
||||||
if grokHome == "" {
|
|
||||||
grokHome = filepath.Join(home, ".grok")
|
|
||||||
}
|
|
||||||
// #nosec G703 -- GROK_HOME is an explicit local-user configuration path.
|
|
||||||
return filepath.Join(grokHome, "hooks", grokConfigFile), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readGrokConfig() (grokDocument, error) {
|
func readGrokDocument() (*grokDocument, error) {
|
||||||
configPath, err := grokConfigPath()
|
configPath, err := grokConfigPath()
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return grokDocument{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
hooks, err := readGrokHooks(root)
|
if snapshot == nil {
|
||||||
if err != nil {
|
return createGrokDocument(configPath), nil
|
||||||
return grokDocument{}, err
|
|
||||||
}
|
}
|
||||||
return grokDocument{exists: exists, configPath: configPath, root: root, hooks: hooks}, nil
|
return parseGrokDocument(configPath, snapshot.contents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func quoteGrokWindowsArgument(value string) string {
|
func prepareRenderedGrokChange(
|
||||||
return `"` + strings.ReplaceAll(value, `"`, `\"`) + `"`
|
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 {
|
func writeGrokChanges(
|
||||||
if runtime.GOOS == "windows" {
|
changes []*fileChange,
|
||||||
return quoteGrokWindowsArgument(runtimePath) + " " + quoteGrokWindowsArgument(scriptPath)
|
label string,
|
||||||
}
|
rename renameFunc,
|
||||||
return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath)
|
runtimeRoot string,
|
||||||
}
|
agentDirectory string,
|
||||||
|
hooksDirectory string,
|
||||||
func buildGrokHandler(command string) map[string]any {
|
) error {
|
||||||
return map[string]any{
|
hasChanges := false
|
||||||
"type": "command",
|
for _, change := range changes {
|
||||||
"command": command,
|
if change != nil {
|
||||||
"timeout": grokHookTimeout,
|
hasChanges = true
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildGrokGroup(handler map[string]any) grokGroup {
|
|
||||||
return grokGroup{object: map[string]any{}, handlers: []map[string]any{handler}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedGrokIDs(groups []grokGroup, scriptName, runtimeRoot string) map[string]string {
|
|
||||||
result := map[string]string{}
|
|
||||||
for _, group := range groups {
|
|
||||||
if _, hasMatcher := group.object["matcher"]; hasMatcher {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, handler := range group.handlers {
|
|
||||||
agentID, managed := managedGrokAgentID(handler, scriptName, runtimeRoot)
|
|
||||||
if managed {
|
|
||||||
key := agentID
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
result[key] = agentID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokRuntimeContracts(hooks grokHooks, runtimeRoot string) []grokRuntimeContract {
|
|
||||||
guards := managedGrokIDs(hooks["PreToolUse"], grokGuardScript, runtimeRoot)
|
|
||||||
audits := managedGrokIDs(hooks["PostToolUse"], grokAuditScript, runtimeRoot)
|
|
||||||
keys := make([]string, 0, len(guards))
|
|
||||||
for key := range guards {
|
|
||||||
if _, exists := audits[key]; exists {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
contracts := make([]grokRuntimeContract, 0, len(keys))
|
|
||||||
for _, key := range keys {
|
|
||||||
agentID := guards[key]
|
|
||||||
contracts = append(contracts, grokRuntimeContract{
|
|
||||||
agentID: agentID,
|
|
||||||
guardPath: filepath.Join(runtimeRoot, agentID, grokGuardScript),
|
|
||||||
auditPath: filepath.Join(runtimeRoot, agentID, grokAuditScript),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return contracts
|
|
||||||
}
|
|
||||||
|
|
||||||
func grokRuntimeScriptsExist(contracts []grokRuntimeContract, runtimeRoot string) (bool, error) {
|
|
||||||
entries, err := os.ReadDir(runtimeRoot)
|
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("read Elydora runtime directory at %s: %w", runtimeRoot, err)
|
|
||||||
}
|
|
||||||
for _, contract := range contracts {
|
|
||||||
var entryName string
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() && sameGrokAgentID(entry.Name(), contract.agentID) {
|
|
||||||
entryName = entry.Name()
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if entryName == "" {
|
if !hasChanges {
|
||||||
continue
|
return nil
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Join(runtimeRoot, entryName)
|
if agentDirectory != "" {
|
||||||
guardPath := filepath.Join(agentDirectory, grokGuardScript)
|
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
||||||
auditPath := filepath.Join(agentDirectory, grokAuditScript)
|
return err
|
||||||
if !sameGrokPath(guardPath, contract.guardPath) ||
|
|
||||||
!sameGrokPath(auditPath, contract.auditPath) {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
||||||
config, exists, err := readHookJSONObject(configPath, "Elydora runtime config")
|
return err
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
agentName, ok := config["agent_name"].(string)
|
if err := ensureManagedDirectory(
|
||||||
if !ok {
|
filepath.Dir(hooksDirectory),
|
||||||
return false, fmt.Errorf(
|
"Grok home directory",
|
||||||
`Elydora runtime config at %s field "agent_name" must be a string`,
|
); err != nil {
|
||||||
configPath,
|
return err
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if agentName != grokAgentKey {
|
if err := ensureManagedDirectory(hooksDirectory, "Grok hooks directory"); err != nil {
|
||||||
continue
|
return err
|
||||||
}
|
}
|
||||||
guardExists, err := regularFileExists(
|
return writeChanges(changes, label, rename)
|
||||||
guardPath, "Elydora guard runtime",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
auditExists, err := regularFileExists(
|
|
||||||
auditPath, "Elydora audit runtime",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return guardExists && auditExists, nil
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -10,37 +9,50 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGrokRegistryAndFactory(t *testing.T) {
|
func TestGrokRegistryFactoryAndRuntimeOwnership(t *testing.T) {
|
||||||
entry := SupportedAgents[grokAgentKey]
|
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)
|
t.Fatalf("Grok registry entry = %#v", entry)
|
||||||
}
|
}
|
||||||
if _, ok := NewPlugin(grokAgentKey).(*GrokPlugin); !ok {
|
plugin, ok := NewPlugin(grokAgentKey).(*GrokPlugin)
|
||||||
t.Fatalf("Grok plugin factory returned %T", NewPlugin(grokAgentKey))
|
if !ok || !plugin.ManagesGuardRuntime() {
|
||||||
|
t.Fatalf("Grok plugin = %#v", NewPlugin(grokAgentKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGrokGeneratedArgumentParsers(t *testing.T) {
|
func TestGrokCommandRoundTripAndRejectsInjection(t *testing.T) {
|
||||||
posixCommand := quotePOSIXArgument("/tmp/node's runtime") + " " +
|
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
||||||
quotePOSIXArgument("/tmp/home's agent/guard.js")
|
nodePath, err := resolveNodeRuntime()
|
||||||
executable, next, ok := readGrokPOSIXArgument(posixCommand, 0)
|
if err != nil {
|
||||||
if !ok || executable != "/tmp/node's runtime" || posixCommand[next] != ' ' {
|
t.Fatalf("resolve Node.js runtime: %v", err)
|
||||||
t.Fatalf("parse POSIX executable = %q, %d, %v", executable, next, ok)
|
|
||||||
}
|
}
|
||||||
script, end, ok := readGrokPOSIXArgument(posixCommand, next+1)
|
command, err := buildGrokCommand(nodePath, fixture.guardPath)
|
||||||
if !ok || script != "/tmp/home's agent/guard.js" || end != len(posixCommand) {
|
if err != nil {
|
||||||
t.Fatalf("parse POSIX script = %q, %d, %v", script, end, ok)
|
t.Fatalf("build Grok command: %v", err)
|
||||||
}
|
}
|
||||||
|
executable, script, ok := parseGrokCommand(command)
|
||||||
windowsCommand := quoteGrokWindowsArgument(`C:\Program Files\node.exe`) + " " +
|
if !ok || !sameGrokPath(executable, nodePath) ||
|
||||||
quoteGrokWindowsArgument(`C:\home with spaces\.elydora\agent-1\guard.js`)
|
!sameGrokPath(script, fixture.guardPath) {
|
||||||
executable, next, ok = readGrokWindowsArgument(windowsCommand, 0)
|
t.Fatalf("parsed Grok command = %q, %q, %v", executable, script, ok)
|
||||||
if !ok || executable != `C:\Program Files\node.exe` || windowsCommand[next] != ' ' {
|
}
|
||||||
t.Fatalf("parse Windows executable = %q, %d, %v", executable, next, 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)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,56 +60,52 @@ func TestGrokInstallPreservesConfigAndIsIdempotent(t *testing.T) {
|
|||||||
existing := `{
|
existing := `{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"SessionStart": [{"matcher":"startup","hooks":[{"type":"http","url":"https://example.test/hook","timeout":5,"headers":{"x":"keep"}}],"label":"keep"}],
|
"SessionStart": [{"hooks":[{"type":"http","url":"https://example.test/hook","timeout":0}],"label":"keep"}],
|
||||||
"PreToolUse": [{"matcher":"Bash","hooks":[{"type":"command","command":"existing-command","timeout":5}]}]
|
"PreToolUse": [{"matcher":"Bash|run_terminal_command","hooks":[{"type":"command","command":"existing-command","timeout":5,"env":{"A":"B"}}]}]
|
||||||
}
|
}
|
||||||
}`
|
}`
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing})
|
fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing})
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
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 {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("repeat Grok install: %v", err)
|
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)
|
settings := readGrokTestObject(t, fixture.configPath)
|
||||||
if settings["schemaVersion"] != float64(1) {
|
if settings["schemaVersion"] != float64(1) {
|
||||||
t.Fatalf("schema version changed: %#v", settings["schemaVersion"])
|
t.Fatalf("schema version changed: %#v", settings["schemaVersion"])
|
||||||
}
|
}
|
||||||
hooks := requireObject(t, settings["hooks"])
|
hooks := requireObject(t, settings["hooks"])
|
||||||
if len(requireArray(t, hooks["SessionStart"])) != 1 ||
|
if len(requireArray(t, hooks["SessionStart"])) != 1 ||
|
||||||
len(requireArray(t, hooks["PreToolUse"])) != 2 ||
|
len(requireArray(t, hooks["PreToolUse"])) != 2 {
|
||||||
len(requireArray(t, hooks["PostToolUse"])) != 1 {
|
t.Fatalf("user hooks changed: %#v", hooks)
|
||||||
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"])
|
|
||||||
}
|
}
|
||||||
|
requireStrictGrokTriple(t, settings)
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
|
filepath.Join(fixture.homeDir, ".grok", "hooks", grokConfigFile),
|
||||||
filepath.Join(fixture.homeDir, ".claude", "settings.json"),
|
filepath.Join(fixture.homeDir, ".claude", "settings.json"),
|
||||||
filepath.Join(fixture.homeDir, ".cursor", "hooks.json"),
|
filepath.Join(fixture.homeDir, ".cursor", "hooks.json"),
|
||||||
filepath.Join(fixture.homeDir, ".grok", "hooks", grokConfigFile),
|
|
||||||
} {
|
} {
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
if _, err := os.Lstat(path); !os.IsNotExist(err) {
|
||||||
t.Fatalf("compatibility or default config was written at %s: %v", path, 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) {
|
func TestGrokEmptyHomeOverrideUsesDefault(t *testing.T) {
|
||||||
@@ -106,208 +114,166 @@ func TestGrokEmptyHomeOverrideUsesDefault(t *testing.T) {
|
|||||||
t.Fatalf("install Grok hooks: %v", err)
|
t.Fatalf("install Grok hooks: %v", err)
|
||||||
}
|
}
|
||||||
status, err := fixture.plugin.Status()
|
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)
|
t.Fatalf("default-home status = %#v, %v", status, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGrokCommandsBlockAndForwardPayloadByteForByte(t *testing.T) {
|
func TestGrokMigratesLegacyCommandsAndPreservesLookalikes(t *testing.T) {
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
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 {
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
t.Fatalf("migrate Grok hooks: %v", err)
|
||||||
}
|
|
||||||
capturePath := filepath.Join(t.TempDir(), "captured-event.json")
|
|
||||||
captureJSON, err := json.Marshal(capturePath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal capture path: %v", err)
|
|
||||||
}
|
|
||||||
captureScript := "const fs = require('node:fs'); const chunks = []; " +
|
|
||||||
"process.stdin.on('data', chunk => chunks.push(chunk)); " +
|
|
||||||
"process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", Buffer.concat(chunks)));\n"
|
|
||||||
if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0700); err != nil {
|
|
||||||
t.Fatalf("write capture hook: %v", err)
|
|
||||||
}
|
}
|
||||||
settings := readGrokTestObject(t, fixture.configPath)
|
settings := readGrokTestObject(t, fixture.configPath)
|
||||||
prePayload := `{"hookEventName":"PreToolUse","sessionId":"session-1","cwd":"workspace","workspaceRoot":"workspace","toolName":"Bash","toolInput":{"command":"echo test"}}`
|
requireStrictGrokTriple(t, settings)
|
||||||
guard := grokTestManagedHandler(t, settings, "PreToolUse", grokGuardScript)
|
raw, _ := os.ReadFile(fixture.configPath)
|
||||||
exitCode, stderr := runGrokCommand(t, guard["command"].(string), fixture.homeDir, prePayload)
|
if !strings.Contains(string(raw), "--inspect") {
|
||||||
if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") {
|
t.Fatalf("ownership lookalike was removed: %s", raw)
|
||||||
t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr)
|
|
||||||
}
|
}
|
||||||
postPayload := `{"hookEventName":"PostToolUse","sessionId":"session-1","cwd":"workspace","workspaceRoot":"workspace","toolName":"Bash","toolInput":{"command":"echo test"},"toolResult":{"output":"test"}}`
|
|
||||||
audit := grokTestManagedHandler(t, settings, "PostToolUse", grokAuditScript)
|
|
||||||
exitCode, stderr = runGrokCommand(t, audit["command"].(string), fixture.homeDir, postPayload)
|
|
||||||
if exitCode != 0 {
|
|
||||||
t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr)
|
|
||||||
}
|
|
||||||
captured, err := os.ReadFile(capturePath)
|
|
||||||
if err != nil || string(captured) != postPayload {
|
|
||||||
t.Fatalf("captured payload = %q, %v", captured, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrokStatusRequiresPairAndBothRuntimes(t *testing.T) {
|
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
status, err := fixture.plugin.Status()
|
|
||||||
if err != nil || !status.Installed || !status.HookConfigured || !status.HookScriptExists {
|
|
||||||
t.Fatalf("installed status = %#v, %v", status, err)
|
|
||||||
}
|
|
||||||
settings := readGrokTestObject(t, fixture.configPath)
|
|
||||||
hooks := requireObject(t, settings["hooks"])
|
hooks := requireObject(t, settings["hooks"])
|
||||||
delete(hooks, "PostToolUse")
|
groups := requireArray(t, hooks["PreToolUse"])
|
||||||
writeGrokTestObject(t, fixture.configPath, settings)
|
managed := requireObject(t, groups[len(groups)-1])
|
||||||
status, err = fixture.plugin.Status()
|
managed["hooks"] = append(
|
||||||
if err != nil || status.Installed || status.HookConfigured {
|
requireArray(t, managed["hooks"]),
|
||||||
t.Fatalf("incomplete status = %#v, %v", status, err)
|
map[string]any{"type": "command", "command": "user-command", "timeout": float64(10)},
|
||||||
}
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("restore Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.Remove(fixture.guardPath); err != nil {
|
|
||||||
t.Fatalf("remove guard: %v", err)
|
|
||||||
}
|
|
||||||
status, err = fixture.plugin.Status()
|
|
||||||
if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists {
|
|
||||||
t.Fatalf("missing-runtime status = %#v, %v", status, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrokUninstallPreservesMixedHandlersAndExactAgent(t *testing.T) {
|
|
||||||
existing := `{"owner":"user","hooks":{"Notification":[]}}`
|
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
settings := readGrokTestObject(t, fixture.configPath)
|
|
||||||
hooks := requireObject(t, settings["hooks"])
|
|
||||||
preGroups := requireArray(t, hooks["PreToolUse"])
|
|
||||||
managedGroup := requireObject(t, preGroups[len(preGroups)-1])
|
|
||||||
managedGroup["hooks"] = append(requireArray(t, managedGroup["hooks"]), map[string]any{
|
|
||||||
"type": "command", "command": "user-command", "timeout": grokHookTimeout,
|
|
||||||
})
|
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve Node.js: %v", err)
|
|
||||||
}
|
|
||||||
preGroups = append(preGroups,
|
|
||||||
map[string]any{"hooks": []any{buildGrokHandler(buildGrokCommand(nodePath, filepath.Join(fixture.agentDir, "guard.js.backup")))}},
|
|
||||||
map[string]any{"hooks": []any{buildGrokHandler(buildGrokCommand(nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-10", grokGuardScript)))}},
|
|
||||||
)
|
)
|
||||||
hooks["PreToolUse"] = preGroups
|
|
||||||
writeGrokTestObject(t, fixture.configPath, settings)
|
writeGrokTestObject(t, fixture.configPath, settings)
|
||||||
uninstallID := grokTestAgentID
|
if err := fixture.plugin.Uninstall(grokTestAgentID); err != nil {
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
uninstallID = "AGENT-1"
|
|
||||||
}
|
|
||||||
if err := fixture.plugin.Uninstall(uninstallID); err != nil {
|
|
||||||
t.Fatalf("uninstall Grok hooks: %v", err)
|
t.Fatalf("uninstall Grok hooks: %v", err)
|
||||||
}
|
}
|
||||||
remaining := readGrokTestObject(t, fixture.configPath)
|
remaining := readGrokTestObject(t, fixture.configPath)
|
||||||
remainingHooks := requireObject(t, remaining["hooks"])
|
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)
|
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) {
|
owned := prepareGrokFixture(t, grokFixtureOptions{})
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
if err := owned.plugin.Install(owned.config); err != nil {
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
t.Fatalf("install owned Grok hooks: %v", err)
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
|
||||||
}
|
}
|
||||||
settings := readGrokTestObject(t, fixture.configPath)
|
if err := owned.plugin.Uninstall(grokTestAgentID); err != nil {
|
||||||
hooks := requireObject(t, settings["hooks"])
|
t.Fatalf("uninstall owned Grok hooks: %v", err)
|
||||||
nodePath, err := resolveNodeRuntime()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resolve Node.js: %v", err)
|
|
||||||
}
|
}
|
||||||
for _, contract := range []struct{ event, script string }{
|
if _, err := os.Lstat(owned.configPath); !os.IsNotExist(err) {
|
||||||
{"PreToolUse", grokGuardScript}, {"PostToolUse", grokAuditScript},
|
|
||||||
} {
|
|
||||||
hooks[contract.event] = append(requireArray(t, hooks[contract.event]), map[string]any{
|
|
||||||
"hooks": []any{buildGrokHandler(buildGrokCommand(
|
|
||||||
nodePath, filepath.Join(filepath.Dir(fixture.agentDir), "agent-old", contract.script),
|
|
||||||
))},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writeGrokTestObject(t, fixture.configPath, settings)
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("replace stale Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
raw, _ := os.ReadFile(fixture.configPath)
|
|
||||||
current := readGrokTestObject(t, fixture.configPath)
|
|
||||||
currentHooks := requireObject(t, current["hooks"])
|
|
||||||
if strings.Contains(string(raw), "agent-old") ||
|
|
||||||
len(requireArray(t, currentHooks["PreToolUse"])) != 1 ||
|
|
||||||
len(requireArray(t, currentHooks["PostToolUse"])) != 1 {
|
|
||||||
t.Fatalf("stale hooks remain: %s", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrokUninstallPreservesUntouchedEmptyGroup(t *testing.T) {
|
|
||||||
existing := `{"owner":"user"}`
|
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{existingRaw: &existing})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
settings := readGrokTestObject(t, fixture.configPath)
|
|
||||||
hooks := requireObject(t, settings["hooks"])
|
|
||||||
hooks["PreToolUse"] = []any{map[string]any{"hooks": []any{}}}
|
|
||||||
writeGrokTestObject(t, fixture.configPath, settings)
|
|
||||||
if err := fixture.plugin.Uninstall(grokTestAgentID); err != nil {
|
|
||||||
t.Fatalf("uninstall Grok hook: %v", err)
|
|
||||||
}
|
|
||||||
remaining := requireObject(t, readGrokTestObject(t, fixture.configPath)["hooks"])
|
|
||||||
preGroups := requireArray(t, remaining["PreToolUse"])
|
|
||||||
if len(preGroups) != 1 || len(requireArray(t, requireObject(t, preGroups[0])["hooks"])) != 0 {
|
|
||||||
t.Fatalf("untouched empty group changed: %#v", remaining)
|
|
||||||
}
|
|
||||||
if _, exists := remaining["PostToolUse"]; exists {
|
|
||||||
t.Fatalf("managed event remains: %#v", remaining)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGrokUninstallRemovesOwnedConfig(t *testing.T) {
|
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
if err := fixture.plugin.Uninstall(grokTestAgentID); err != nil {
|
|
||||||
t.Fatalf("uninstall Grok hooks: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(fixture.configPath); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("owned Grok config remains: %v", err)
|
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 }{
|
for _, testCase := range []struct{ name, raw, want string }{
|
||||||
{"malformed", "{ malformed", "parse Grok hooks config"},
|
{"malformed", "{ malformed", "parse Grok user hooks"},
|
||||||
{"null-root", "null", "must contain a JSON object"},
|
{"comments", "{\"hooks\":{} // comment\n}", "invalid character"},
|
||||||
{"null-hooks", `{"hooks":null}`, `field "hooks" must be an object`},
|
{"trailing comma", `{"hooks":{},}`, "invalid character"},
|
||||||
{"null-event", `{"hooks":{"PreToolUse":null}}`, `field "hooks.PreToolUse" must be an array`},
|
{"non-finite timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":NaN}]}]}}`, "invalid character"},
|
||||||
{"null-group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"},
|
{"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate"},
|
||||||
{"null-matcher", `{"hooks":{"PreToolUse":[{"matcher":null,"hooks":[]}]}}`, "matcher must be a string"},
|
{"hooks", `{"hooks":null}`, `field "hooks" must be an object`},
|
||||||
{"null-handlers", `{"hooks":{"PreToolUse":[{"hooks":null}]}}`, "must contain a hooks array"},
|
{"event", `{"hooks":{"PreToolUse":null}}`, "must be an array"},
|
||||||
{"empty-command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "requires a non-empty command"},
|
{"group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"},
|
||||||
{"object-type", `{"hooks":{"PreToolUse":[{"hooks":[{"type":{},"command":"x"}]}]}}`, "unsupported type"},
|
{"matcher", `{"hooks":{"PreToolUse":[{"matcher":1,"hooks":[]}]}}`, "matcher must be a string"},
|
||||||
{"empty-url", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","url":""}]}]}}`, "requires a non-empty url"},
|
{"lifecycle matcher", `{"hooks":{"SessionStart":[{"matcher":"x","hooks":[]}]}}`, "cannot declare a matcher"},
|
||||||
{"zero-timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite number"},
|
{"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) {
|
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)
|
err := fixture.plugin.Install(fixture.config)
|
||||||
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
if err == nil || !strings.Contains(err.Error(), testCase.want) {
|
||||||
t.Fatalf("install error = %v, want %q", err, 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 {
|
if readErr != nil || string(raw) != testCase.raw {
|
||||||
t.Fatalf("original config changed: %q, %v", raw, readErr)
|
t.Fatalf("original config changed: %q, %v", raw, readErr)
|
||||||
}
|
}
|
||||||
for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
assertNoGrokRuntimeWrites(t, fixture)
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
|
||||||
t.Fatalf("runtime write occurred at %s: %v", path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGrokMissingGuardPreventsWrites(t *testing.T) {
|
func TestGrokRejectsRenderedConfigBeyondManagedSourceLimit(t *testing.T) {
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{withoutGuard: true})
|
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)
|
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)
|
t.Fatalf("install error = %v", err)
|
||||||
}
|
}
|
||||||
for _, path := range []string{fixture.configPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} {
|
current, readErr := os.ReadFile(fixture.configPath)
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
if readErr != nil || string(current) != raw {
|
||||||
t.Fatalf("write occurred at %s: %v", path, err)
|
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 := prepareGrokFixture(t, grokFixtureOptions{})
|
||||||
|
fixture.config.Token = ""
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
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 {
|
config := readGrokTestObject(t, fixture.runtimeConfig)
|
||||||
t.Fatalf("corrupt runtime config: %v", err)
|
if _, exists := config["token"]; exists {
|
||||||
}
|
t.Fatalf("empty optional token was persisted: %#v", config)
|
||||||
if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") {
|
|
||||||
t.Fatalf("status error = %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGrokAtomicWritesLeaveNoTemporaryFiles(t *testing.T) {
|
func TestGrokWindowsAgentIDMatchingIsPlatformCorrect(t *testing.T) {
|
||||||
fixture := prepareGrokFixture(t, grokFixtureOptions{})
|
if runtime.GOOS == "windows" {
|
||||||
if err := fixture.plugin.Install(fixture.config); err != nil {
|
if !sameGrokAgentID("AGENT-1", "agent-1") {
|
||||||
t.Fatalf("install Grok hooks: %v", err)
|
t.Fatal("Windows Grok agent IDs should compare case-insensitively")
|
||||||
}
|
}
|
||||||
entries, err := os.ReadDir(filepath.Dir(fixture.configPath))
|
return
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
if sameGrokAgentID("AGENT-1", "agent-1") {
|
||||||
|
t.Fatal("POSIX Grok agent IDs should preserve case")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,23 +7,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
const grokTestAgentID = "agent-1"
|
const (
|
||||||
|
grokTestAgentID = "agent-1"
|
||||||
|
grokPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc"
|
||||||
|
)
|
||||||
|
|
||||||
type grokFixtureOptions struct {
|
type grokFixtureOptions struct {
|
||||||
existingRaw *string
|
existingRaw *string
|
||||||
useDefaultHome bool
|
useDefaultHome bool
|
||||||
withoutGuard bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type grokFixture struct {
|
type grokFixture struct {
|
||||||
plugin *GrokPlugin
|
plugin *GrokPlugin
|
||||||
config InstallConfig
|
config InstallConfig
|
||||||
homeDir string
|
homeDir string
|
||||||
|
projectDir string
|
||||||
grokHome string
|
grokHome string
|
||||||
agentDir string
|
agentDir string
|
||||||
configPath string
|
configPath string
|
||||||
@@ -39,25 +43,20 @@ func grokString(value string) *string {
|
|||||||
|
|
||||||
func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture {
|
func prepareGrokFixture(t *testing.T, options grokFixtureOptions) *grokFixture {
|
||||||
t.Helper()
|
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")
|
grokHome := filepath.Join(homeDir, "custom grok")
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
grokHome = filepath.Join(homeDir, ".grok")
|
grokHome = filepath.Join(homeDir, ".grok")
|
||||||
}
|
}
|
||||||
agentDir := filepath.Join(homeDir, ".elydora", grokTestAgentID)
|
agentDir := filepath.Join(homeDir, ".elydora", grokTestAgentID)
|
||||||
guardPath := filepath.Join(agentDir, grokGuardScript)
|
|
||||||
configPath := filepath.Join(grokHome, "hooks", grokConfigFile)
|
configPath := filepath.Join(grokHome, "hooks", grokConfigFile)
|
||||||
if err := os.MkdirAll(agentDir, 0755); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create agent directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
}
|
|
||||||
if !options.withoutGuard {
|
|
||||||
source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"
|
|
||||||
if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil {
|
|
||||||
t.Fatalf("write guard runtime: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if options.existingRaw != nil {
|
if 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)
|
t.Fatalf("create Grok config directory: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil {
|
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("HOME", homeDir)
|
||||||
t.Setenv("USERPROFILE", homeDir)
|
t.Setenv("USERPROFILE", homeDir)
|
||||||
|
t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-command-fragment"))
|
||||||
if options.useDefaultHome {
|
if options.useDefaultHome {
|
||||||
t.Setenv("GROK_HOME", "")
|
t.Setenv("GROK_HOME", "")
|
||||||
} else {
|
} else {
|
||||||
t.Setenv("GROK_HOME", grokHome)
|
t.Setenv("GROK_HOME", grokHome)
|
||||||
}
|
}
|
||||||
|
guardPath := filepath.Join(agentDir, grokGuardScript)
|
||||||
return &grokFixture{
|
return &grokFixture{
|
||||||
plugin: &GrokPlugin{},
|
plugin: &GrokPlugin{}, config: InstallConfig{
|
||||||
homeDir: homeDir,
|
AgentName: grokAgentKey, OrgID: "org-1", AgentID: grokTestAgentID,
|
||||||
grokHome: grokHome,
|
PrivateKey: grokPrivateKey, KID: "kid-1", Token: "token-1",
|
||||||
agentDir: agentDir,
|
BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath,
|
||||||
config: InstallConfig{
|
|
||||||
AgentName: grokAgentKey,
|
|
||||||
OrgID: "org-1",
|
|
||||||
AgentID: grokTestAgentID,
|
|
||||||
PrivateKey: "test-key",
|
|
||||||
KID: "kid-1",
|
|
||||||
BaseURL: "https://api.elydora.test",
|
|
||||||
GuardScriptPath: guardPath,
|
|
||||||
},
|
},
|
||||||
configPath: configPath,
|
homeDir: homeDir, projectDir: projectDir, grokHome: grokHome,
|
||||||
guardPath: guardPath,
|
agentDir: agentDir, configPath: configPath, guardPath: guardPath,
|
||||||
hookPath: filepath.Join(agentDir, grokAuditScript),
|
hookPath: filepath.Join(agentDir, grokAuditScript),
|
||||||
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
runtimeConfig: filepath.Join(agentDir, "config.json"),
|
||||||
privateKey: filepath.Join(agentDir, "private.key"),
|
privateKey: filepath.Join(agentDir, "private.key"),
|
||||||
@@ -106,18 +99,23 @@ func readGrokTestObject(t *testing.T, path string) map[string]any {
|
|||||||
return value
|
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()
|
t.Helper()
|
||||||
hooks := requireObject(t, settings["hooks"])
|
hooks := requireObject(t, settings["hooks"])
|
||||||
for _, groupValue := range requireArray(t, hooks[event]) {
|
for _, groupValue := range requireArray(t, hooks[event]) {
|
||||||
group := requireObject(t, groupValue)
|
group := requireObject(t, groupValue)
|
||||||
if _, hasMatcher := group["matcher"]; hasMatcher {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, handlerValue := range requireArray(t, group["hooks"]) {
|
for _, handlerValue := range requireArray(t, group["hooks"]) {
|
||||||
handler := requireObject(t, handlerValue)
|
handler := requireObject(t, handlerValue)
|
||||||
command, _ := handler["command"].(string)
|
reference, err := managedGrokReference(handler, scriptName)
|
||||||
if strings.Contains(command, scriptName) {
|
if err != nil {
|
||||||
|
t.Fatalf("inspect managed %s handler: %v", event, err)
|
||||||
|
}
|
||||||
|
if reference != nil {
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,32 +124,136 @@ func grokTestManagedHandler(t *testing.T, settings map[string]any, event, script
|
|||||||
return nil
|
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()
|
t.Helper()
|
||||||
var process *exec.Cmd
|
var process *exec.Cmd
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
commandFile := filepath.Join(t.TempDir(), "run-grok-hook.cmd")
|
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)
|
t.Fatalf("write Grok command file: %v", err)
|
||||||
}
|
}
|
||||||
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
process = exec.Command("cmd.exe", "/d", "/c", commandFile)
|
||||||
} else {
|
} else {
|
||||||
process = exec.Command("sh", "-c", command)
|
process = exec.Command("sh", "-c", command)
|
||||||
}
|
}
|
||||||
process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir)
|
process.Dir = fixture.projectDir
|
||||||
process.Stdin = bytes.NewBufferString(payload)
|
process.Env = append(
|
||||||
var stderr bytes.Buffer
|
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
|
process.Stderr = &stderr
|
||||||
err := process.Run()
|
err := process.Run()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return 0, stderr.String()
|
return 0, stdout.String(), stderr.String()
|
||||||
}
|
}
|
||||||
var exitError *exec.ExitError
|
var exitError *exec.ExitError
|
||||||
if errors.As(err, &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)
|
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) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -4,10 +4,14 @@ import "fmt"
|
|||||||
|
|
||||||
// GenerateGuardScript returns a self-contained agent status guard.
|
// GenerateGuardScript returns a self-contained agent status guard.
|
||||||
func GenerateGuardScript(agentName string, agentID string) string {
|
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
|
return fmt.Sprintf(`#!/usr/bin/env node
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@@ -21,6 +25,7 @@ const AGENT_NAME = %q;
|
|||||||
const AGENT_ID = %q;
|
const AGENT_ID = %q;
|
||||||
const SUCCESS_OUTPUT = %q;
|
const SUCCESS_OUTPUT = %q;
|
||||||
const FAIL_CLOSED = %t;
|
const FAIL_CLOSED = %t;
|
||||||
|
const DENY_PROTOCOL = %q;
|
||||||
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
const ELYDORA_DIR = path.join(os.homedir(), '.elydora');
|
||||||
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
const CONFIG_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'config.json');
|
||||||
const STATUS_CACHE_PATH = path.join(ELYDORA_DIR, AGENT_ID, 'status-cache.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) {
|
function blockAgent(status) {
|
||||||
const state = status === 'frozen' ? 'is frozen by Elydora' : 'is revoked in Elydora';
|
const state = status === 'frozen' ? 'is frozen by Elydora' : 'is revoked in Elydora';
|
||||||
const message = 'Agent "' + AGENT_NAME + '" ' + state + '.';
|
const message = 'Agent "' + AGENT_NAME + '" ' + state + '.';
|
||||||
if (SUCCESS_OUTPUT) {
|
if (DENY_PROTOCOL === 'cursor') {
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
permission: 'deny',
|
permission: 'deny',
|
||||||
userMessage: message,
|
userMessage: message,
|
||||||
agentMessage: message + ' Tool execution is blocked.',
|
agentMessage: message + ' Tool execution is blocked.',
|
||||||
}) + '\n');
|
}) + '\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.stderr.write('[Elydora guard] ' + message + ' Tool execution blocked.\n');
|
||||||
process.exitCode = 2;
|
process.exitCode = 2;
|
||||||
@@ -221,5 +231,13 @@ main().then(() => {
|
|||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
`, agentName, agentName, agentID, successOutput, failClosed, protectedRuntimeFileReader)
|
`,
|
||||||
|
agentName,
|
||||||
|
agentName,
|
||||||
|
agentID,
|
||||||
|
successOutput,
|
||||||
|
failClosed,
|
||||||
|
denyProtocol,
|
||||||
|
protectedRuntimeFileReader,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@@ -186,7 +186,7 @@ func prepareKimiInstallationChanges(
|
|||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
paths.guardPath, "Elydora guard runtime",
|
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.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
||||||
|
|||||||
@@ -63,6 +63,14 @@ func prepareSourceChange(
|
|||||||
if !existed {
|
if !existed {
|
||||||
original = nil
|
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)
|
snapshot, err := readManagedFile(filePath, label, maxManagedSourceBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in new issue
Block a user