diff --git a/AGENTS.md b/AGENTS.md index 5a1dc49..0d7eea5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Generate Kimi guard, config, private key, and audit runtimes together with every selected TOML document in one rollback-capable transaction. Preflight physical paths and existing runtime identity before creating state; status validates the complete runtime metadata, canonical private key, exact agent identity, and non-empty physical scripts. - Write Claude Code hooks only to `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` as the default; keep project, local, managed, plugin, skill, and agent sources read-only. Preserve the official exec-form command schema and native snake_case payload, own one exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple, propagate freeze and revocation through exit code `2`, and treat `disableAllHooks` as an explicit installation and health gate. - Generate Claude Code guard, config, private key, and audit runtimes with user settings in one rollback-capable transaction. Validate the full official event and handler schema before writes, migrate only the exact prior Go command contract, require strict runtime identity for health, and direct users to `/hooks` plus `claude doctor` for effective-source verification. +- Write Gemini CLI hooks only to `$GEMINI_CLI_HOME/.gemini/settings.json` with `~/.gemini/settings.json` as the default. Keep workspace, system defaults, system override, and extension settings read-only. Preserve JSONC comments and line endings, reject trailing commas and duplicate keys, validate the official event and command-handler schema, and treat `hooksConfig.enabled` plus `hooksConfig.disabled` as installation and health gates. +- Own one exact matchless `BeforeTool`/`AfterTool` pair named `elydora-guard` and `elydora-audit` with 10,000 millisecond timeouts. Generate guard, config, private key, and native-payload audit runtimes with user settings in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit `{}` for successful hook execution, propagate frozen and revoked agents through exit code `2`, require strict runtime identity for health, and direct users to `/hooks list` for effective-source verification. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat project `.grok/hooks`, plugin hooks, Claude Code, and Cursor compatibility files as read-only integration sources. Own only exact matchless three-field command groups for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; accept official non-negative integer timeouts and string-valued environments; reject lifecycle matchers; and preserve native camelCase payloads. - Generate Grok guard, config, private key, and audit runtimes with the user hook document in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit native deny JSON with exit code `2`, preflight physical paths and runtime identity, and require one exact event triple plus strict runtime metadata and a canonical private key for healthy status. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. diff --git a/README.md b/README.md index c2a4de5..2eb96c2 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to Cursor installation writes native global `preToolUse`, `postToolUse`, and `postToolUseFailure` handlers to `~/.cursor/hooks.json`. Elydora preserves unrelated user hooks, leaves project and enterprise sources unchanged, emits valid JSON hook responses, fails closed when enforcement or audit execution cannot complete, and commits its 10-second hook contract with all managed runtime files as one rollback-capable transaction. +Gemini CLI installation writes exact matchless `BeforeTool` and `AfterTool` command handlers to `$GEMINI_CLI_HOME/.gemini/settings.json` (`~/.gemini/settings.json` by default). Elydora preserves JSONC comments, line endings, unrelated user hooks, and additive workspace, system, system-override, and extension sources. The user settings document and all four generated runtime artifacts commit in one rollback-capable transaction. Generated hooks retain Gemini's native snake_case payload, emit `{}` on successful or fail-open execution, surface runtime failures through stderr and `error.log`, and use exit code `2` for frozen or revoked agents. Installation honors `hooksConfig` controls and uses encoded PowerShell commands on Windows. Run `/hooks list` after installation to inspect the effective hook inventory. + GitHub Copilot CLI installation writes native user hooks to `$COPILOT_HOME/hooks/elydora-audit.json` with `~/.copilot/hooks/elydora-audit.json` as the default. Elydora preserves user hooks, migrates exact Elydora-owned entries from project `.github/hooks/hooks.json`, quotes the resolved Node.js runtime for each shell, and propagates the freeze guard's exit code. Installation requires `disableAllHooks` to be false. Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/hooks` (default `~/.cline/hooks`). Elydora leaves the Documents and workspace hook roots unchanged. The wrappers preserve Cline's official payload and translate a frozen guard into JSON stdout cancellation control. diff --git a/cmd/elydora/plugins/gemini.go b/cmd/elydora/plugins/gemini.go index 88ba0a0..860a1da 100644 --- a/cmd/elydora/plugins/gemini.go +++ b/cmd/elydora/plugins/gemini.go @@ -2,213 +2,162 @@ package plugins import ( "fmt" - "os" "path/filepath" ) -// GeminiPlugin manages the Elydora audit hook for Gemini CLI. -// It merges an AfterTool hook into ~/.gemini/settings.json. -type GeminiPlugin struct{} +// GeminiPlugin manages Gemini CLI global user hooks. +type GeminiPlugin struct { + rename renameFunc +} + +// ManagesGuardRuntime reports that Gemini commits both generated runtimes and +// the user settings document through one transaction. +func (p *GeminiPlugin) ManagesGuardRuntime() bool { + return true +} + +func requireGeminiHooksEnabled(document *geminiDocument) error { + if !document.hookControls.enabled { + return fmt.Errorf( + "Gemini CLI hooks are disabled by hooksConfig.enabled: %s", + document.filePath, + ) + } + disabled, err := disabledManagedGeminiEntries(document.hookControls) + if err != nil { + return err + } + if len(disabled) > 0 { + return fmt.Errorf( + "Gemini CLI hooks are disabled by hooksConfig.disabled: %v", + disabled, + ) + } + return nil +} + +// PreflightInstall validates settings and runtime identity before any write. +func (p *GeminiPlugin) PreflightInstall(config InstallConfig) error { + document, err := readGeminiDocument() + if err != nil { + return err + } + if err := requireGeminiHooksEnabled(document); err != nil { + return err + } + _, _, err = preflightGeminiInstallation(config, document) + return err +} func (p *GeminiPlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath(config.AgentID) + document, err := readGeminiDocument() if err != nil { return err } - if config.HookScript != "" { - scriptPath = config.HookScript + if err := requireGeminiHooksEnabled(document); err != nil { + return err } - - if err := GenerateHookScript(scriptPath, config); err != nil { - return fmt.Errorf("generate hook script: %w", err) - } - - guardPath := config.GuardScriptPath - if guardPath == "" { - guardPath, err = guardScriptPath(config.AgentID) - if err != nil { - return err - } - } - - configDir, err := expandHome("~/.gemini") + paths, nodePath, err := preflightGeminiInstallation(config, document) if err != nil { return err } - configPath := filepath.Join(configDir, "settings.json") - - settings, err := readJSONFile(configPath) + guardGroup, err := buildGeminiGroup( + nodePath, + paths.guardPath, + geminiGuardHookName, + ) if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - hooks = make(map[string]interface{}) + auditGroup, err := buildGeminiGroup( + nodePath, + paths.auditPath, + geminiAuditHookName, + ) + if err != nil { + return err } - - // --- BeforeTool (guard — freeze enforcement) --- - beforeTool, _ := hooks["BeforeTool"].([]interface{}) - var preFiltered []interface{} - for _, entry := range beforeTool { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) - } - guardEntry := map[string]interface{}{ - "hooks": []interface{}{ - map[string]interface{}{ - "type": "command", - "command": "node " + guardPath, - }, + rendered, err := renderGeminiDocument( + document, + "", + map[string]map[string]any{ + "BeforeTool": guardGroup, + "AfterTool": auditGroup, }, + ) + if err != nil { + return fmt.Errorf("render Gemini CLI user settings: %w", err) } - preFiltered = append(preFiltered, guardEntry) - hooks["BeforeTool"] = preFiltered - - // --- AfterTool (audit logging) --- - afterTool, _ := hooks["AfterTool"].([]interface{}) - var postFiltered []interface{} - for _, entry := range afterTool { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - postFiltered = append(postFiltered, entry) - } - hookEntry := map[string]interface{}{ - "hooks": []interface{}{ - map[string]interface{}{ - "type": "command", - "command": "node " + scriptPath, - }, - }, - } - postFiltered = append(postFiltered, hookEntry) - hooks["AfterTool"] = postFiltered - - settings["hooks"] = hooks - - if err := writeJSONFile(configPath, settings); err != nil { + changes, err := prepareGeminiInstallationChanges(config, paths, rendered) + if err != nil { return err } - fmt.Printf("Installed Elydora hook for Gemini CLI at %s\n", configPath) + if err := writeGeminiChanges( + changes, + "Install Gemini CLI hooks", + p.rename, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.filePath), + ); err != nil { + return err + } + fmt.Printf("Gemini CLI hooks installed at %s.\n", document.filePath) + fmt.Println("Gemini CLI verification: run /hooks list.") return nil } func (p *GeminiPlugin) Uninstall(agentID string) error { - configDir, err := expandHome("~/.gemini") + document, err := readGeminiDocument() if err != nil { return err } - configPath := filepath.Join(configDir, "settings.json") - - settings, err := readJSONFile(configPath) + rendered, err := renderGeminiDocument(document, agentID, nil) + if err != nil { + return fmt.Errorf("render Gemini CLI user settings: %w", err) + } + change, err := prepareRenderedGeminiChange(rendered) if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - fmt.Println("No Gemini CLI hooks found.") - return nil - } - - // Remove BeforeTool Elydora entries - beforeTool, _ := hooks["BeforeTool"].([]interface{}) - var preFiltered []interface{} - for _, entry := range beforeTool { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) - } - if len(preFiltered) == 0 { - delete(hooks, "BeforeTool") - } else { - hooks["BeforeTool"] = preFiltered - } - - // Remove AfterTool Elydora entries - afterTool, _ := hooks["AfterTool"].([]interface{}) - var postFiltered []interface{} - for _, entry := range afterTool { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - postFiltered = append(postFiltered, entry) - } - if len(postFiltered) == 0 { - delete(hooks, "AfterTool") - } else { - hooks["AfterTool"] = postFiltered - } - - if len(hooks) == 0 { - delete(settings, "hooks") - } else { - settings["hooks"] = hooks - } - - if err := writeJSONFile(configPath, settings); err != nil { - return err - } - - if agentID != "" { - scriptPath, _ := hookScriptPath(agentID) - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath(agentID) - if gPath != "" { - os.Remove(gPath) - } - } - fmt.Println("Uninstalled Elydora hook for Gemini CLI.") - return nil + return writeGeminiChanges( + []*fileChange{change}, + "Uninstall Gemini CLI hooks", + p.rename, + "", + "", + filepath.Dir(document.filePath), + ) } func (p *GeminiPlugin) Status() (PluginStatus, error) { - configDir, err := expandHome("~/.gemini") - if err != nil { - return PluginStatus{}, err - } - configPath := filepath.Join(configDir, "settings.json") - + configPath, pathErr := geminiSettingsPath() + entry := SupportedAgents[geminiAgentKey] status := PluginStatus{ - AgentName: "gemini", - DisplayName: "Gemini CLI", - ConfigPath: configPath, + AgentName: geminiAgentKey, DisplayName: entry.Name, ConfigPath: configPath, } - - settings, err := readJSONFile(configPath) + if pathErr != nil { + return status, pathErr + } + document, err := readGeminiDocument() if err != nil { + return status, err + } + if !managedGeminiHooksEnabled(document.hookControls) { return status, nil } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks != nil { - preConfigured := hasElydoraEntry(hooks["BeforeTool"]) - postConfigured := hasElydoraEntry(hooks["AfterTool"]) - status.HookConfigured = preConfigured && postConfigured - - // Extract hook script path from the configured command - scriptPath := extractElydoraScriptPath(hooks["AfterTool"]) - if scriptPath != "" { - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - } + contracts, err := geminiRuntimeContracts(document.hooks) + if err != nil { + return status, err } - - status.Installed = status.HookConfigured && status.HookScriptExists + status.HookConfigured = len(contracts) > 0 + if !status.HookConfigured { + return status, nil + } + status.HookScriptExists, err = geminiRuntimeFilesExist(contracts) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists return status, nil } diff --git a/cmd/elydora/plugins/gemini_cli_test.go b/cmd/elydora/plugins/gemini_cli_test.go new file mode 100644 index 0000000..54a671f --- /dev/null +++ b/cmd/elydora/plugins/gemini_cli_test.go @@ -0,0 +1,111 @@ +package plugins + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func geminiRunCLI( + t *testing.T, + binary string, + fixture *geminiFixture, + args ...string, +) (string, string, int) { + t.Helper() + process := exec.Command(binary, args...) + process.Dir = fixture.projectDir + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "GEMINI_CLI_HOME="+fixture.geminiHome, + ) + var stdout, stderr strings.Builder + process.Stdout = &stdout + process.Stderr = &stderr + err := process.Run() + if err == nil { + return stdout.String(), stderr.String(), 0 + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return stdout.String(), stderr.String(), exitError.ExitCode() + } + t.Fatalf("run Elydora CLI: %v", err) + return stdout.String(), stderr.String(), -1 +} + +func TestGeminiCLIInstallStatusAndUninstallEndToEnd(t *testing.T) { + _, sourceFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve test source path") + } + repository := filepath.Clean(filepath.Join(filepath.Dir(sourceFile), "..", "..", "..")) + binary := filepath.Join(t.TempDir(), "elydora") + if runtime.GOOS == "windows" { + binary += ".exe" + } + build := exec.Command("go", "build", "-o", binary, "./cmd/elydora") + build.Dir = repository + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build Elydora CLI: %v\n%s", err, output) + } + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + privateKeyFile := filepath.Join(t.TempDir(), "private.key") + tokenFile := filepath.Join(t.TempDir(), "api.token") + if err := os.WriteFile(privateKeyFile, []byte(geminiPrivateKey+"\n"), 0600); err != nil { + t.Fatalf("write private key input: %v", err) + } + if err := os.WriteFile(tokenFile, []byte("token-1\n"), 0600); err != nil { + t.Fatalf("write token input: %v", err) + } + stdout, stderr, exit := geminiRunCLI( + t, + binary, + fixture, + "install", + "--agent", geminiAgentKey, + "--org-id", "org-1", + "--agent-id", geminiTestAgentID, + "--private-key-file", privateKeyFile, + "--token-file", tokenFile, + "--kid", "kid-1", + "--base-url", "http://127.0.0.1:9", + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "/hooks list") { + t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr) + } + requireStrictGeminiPair(t, readGeminiTestObject(t, fixture.settingsPath)) + stdout, stderr, exit = geminiRunCLI( + t, + binary, + fixture, + "status", + "--agent", geminiAgentKey, + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") { + t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr) + } + stdout, stderr, exit = geminiRunCLI( + t, + binary, + fixture, + "uninstall", + "--agent", geminiAgentKey, + "--agent-id", geminiTestAgentID, + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "Removed agent directory") { + t.Fatalf("CLI uninstall = %d, %q, %q", exit, stdout, stderr) + } + if _, err := os.Lstat(fixture.settingsPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed settings remain after CLI uninstall: %v", err) + } + if _, err := os.Lstat(fixture.agentDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime remains after CLI uninstall: %v", err) + } +} diff --git a/cmd/elydora/plugins/gemini_command.go b/cmd/elydora/plugins/gemini_command.go new file mode 100644 index 0000000..d7f4b62 --- /dev/null +++ b/cmd/elydora/plugins/gemini_command.go @@ -0,0 +1,132 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +type geminiRuntimeReference struct { + agentID string + scriptPath string +} + +func sameGeminiPath(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 sameGeminiAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameGeminiFileName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func isGeminiNodeExecutable(path string) bool { + name := filepath.Base(path) + return name == "node" || strings.EqualFold(name, "node.exe") +} + +func buildGeminiCommand(runtimePath, scriptPath string) (string, error) { + if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { + return "", fmt.Errorf( + "Gemini CLI hook commands require absolute runtime and script paths", + ) + } + if runtime.GOOS != "windows" { + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil + } + source := "& " + quoteCodexPowerShell(runtimePath) + " " + + quoteCodexPowerShell(scriptPath) + "; exit $LASTEXITCODE" + return "& " + quoteCodexPowerShell(codexPowerShellPath()) + + " -NoLogo -NoProfile -NonInteractive -EncodedCommand " + + encodeCodexPowerShell(source), nil +} + +func parseGeminiWindowsCommand(command string) (string, string, bool) { + if !strings.HasPrefix(command, "& ") { + return "", "", false + } + powerShell, next, ok := readCodexPowerShellArgument(command, 2) + if !ok || !isAbsoluteWindowsPath(powerShell) || + !strings.EqualFold(windowsPathBase(powerShell), "powershell.exe") { + return "", "", false + } + prefix := " -NoLogo -NoProfile -NonInteractive -EncodedCommand " + if !strings.HasPrefix(command[next:], prefix) { + return "", "", false + } + source, ok := decodeCodexPowerShell(command[next+len(prefix):]) + if !ok { + return "", "", false + } + return parseCodexPowerShellSource(source) +} + +func parseGeminiCommand(command string) (string, string, bool) { + if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + return runtimePath, scriptPath, true + } + return parseGeminiWindowsCommand(command) +} + +func parseLegacyGeminiCommand(command string) (string, string, bool) { + if !strings.HasPrefix(command, "node ") || strings.ContainsAny(command, "\r\n") { + return "", "", false + } + scriptPath := strings.TrimPrefix(command, "node ") + if scriptPath == "" || strings.TrimSpace(scriptPath) != scriptPath { + return "", "", false + } + return "node", scriptPath, true +} + +func geminiRuntimeReferenceForCommand( + command string, + scriptName string, + includeLegacy bool, +) (*geminiRuntimeReference, error) { + runtimePath, scriptPath, current := parseGeminiCommand(command) + legacy := false + if !current && includeLegacy { + runtimePath, scriptPath, legacy = parseLegacyGeminiCommand(command) + } + validRuntime := (current && filepath.IsAbs(runtimePath) && + isGeminiNodeExecutable(runtimePath)) || (legacy && runtimePath == "node") + if !validRuntime || !filepath.IsAbs(scriptPath) || + !sameGeminiFileName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &geminiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil +} diff --git a/cmd/elydora/plugins/gemini_config.go b/cmd/elydora/plugins/gemini_config.go new file mode 100644 index 0000000..89f892e --- /dev/null +++ b/cmd/elydora/plugins/gemini_config.go @@ -0,0 +1,229 @@ +package plugins + +import ( + "bytes" + "fmt" + "reflect" + "sort" +) + +type geminiDocument struct { + filePath string + exists bool + raw []byte + root map[string]any + hooks geminiHooks + hookControls geminiHookControls + hasHooksContainer bool + ownedFile bool +} + +type geminiRenderedDocument struct { + document *geminiDocument + changed bool + next []byte + remove bool +} + +func geminiDocumentLabel(filePath string) string { + return fmt.Sprintf("Gemini CLI user settings at %s", filePath) +} + +func parseGeminiDocument( + exists bool, + filePath string, + raw []byte, +) (*geminiDocument, error) { + label := geminiDocumentLabel(filePath) + root, err := decodeJSONCObject(raw, label, false) + if err != nil { + return nil, err + } + hooks, err := readGeminiHooks(root) + if err != nil { + return nil, err + } + controls, err := readGeminiHookControls(root) + if err != nil { + return nil, err + } + _, hasHooks := root["hooks"] + return &geminiDocument{ + filePath: filePath, exists: exists, raw: append([]byte(nil), raw...), + root: root, hooks: hooks, hookControls: controls, + hasHooksContainer: hasHooks, + ownedFile: bytes.HasPrefix(raw, []byte(geminiOwnedFileMarker)), + }, nil +} + +func createGeminiDocument(filePath string) (*geminiDocument, error) { + return parseGeminiDocument( + false, + filePath, + []byte(geminiOwnedFileMarker+"\n{}\n"), + ) +} + +func geminiAlreadyInstalled( + document *geminiDocument, + additions map[string]map[string]any, +) (bool, error) { + if len(additions) != len(geminiManagedEvents) { + return false, nil + } + removals, err := managedGeminiRemovals(document.hooks, "") + if err != nil { + return false, err + } + for _, event := range geminiManagedEvents { + expected, exists := additions[event] + if !exists { + return false, nil + } + removalCount := 0 + removeGroup := false + for _, removal := range removals { + if removal.event == event { + removalCount++ + removeGroup = removal.removeGroup + } + } + exactCount := 0 + for _, group := range document.hooks[event] { + if reflect.DeepEqual(group, expected) { + exactCount++ + } + } + if removalCount != 1 || !removeGroup || exactCount != 1 { + return false, nil + } + } + return true, nil +} + +func renderGeminiDocument( + document *geminiDocument, + agentID string, + additions map[string]map[string]any, +) (*geminiRenderedDocument, error) { + if agentID == "" && len(additions) > 0 { + installed, err := geminiAlreadyInstalled(document, additions) + if err != nil { + return nil, err + } + if installed { + return &geminiRenderedDocument{document: document}, nil + } + } + label := geminiDocumentLabel(document.filePath) + editor, err := newJSONCEditor(document.raw, label, false) + if err != nil { + return nil, err + } + removals, err := managedGeminiRemovals(document.hooks, agentID) + if err != nil { + return nil, err + } + for _, event := range geminiManagedEvents { + eventRemovals := make([]geminiManagedRemoval, 0) + for _, removal := range removals { + if removal.event == event { + eventRemovals = append(eventRemovals, removal) + } + } + sort.Slice(eventRemovals, func(left, right int) bool { + return eventRemovals[left].groupIndex > eventRemovals[right].groupIndex + }) + for _, removal := range eventRemovals { + groupPath := []any{"hooks", event, removal.groupIndex} + if removal.removeGroup { + if err := editor.remove(groupPath); err != nil { + return nil, err + } + continue + } + sort.Sort(sort.Reverse(sort.IntSlice(removal.handlerIndexes))) + for _, handlerIndex := range removal.handlerIndexes { + if err := editor.remove( + appendGeminiPath(groupPath, "hooks", handlerIndex), + ); err != nil { + return nil, err + } + } + } + if len(eventRemovals) > 0 { + current, parseErr := parseGeminiDocument( + document.exists, + document.filePath, + editor.pack(), + ) + if parseErr != nil { + return nil, parseErr + } + if groups, exists := current.hooks[event]; exists && len(groups) == 0 { + if err := editor.remove([]any{"hooks", event}); err != nil { + return nil, err + } + } + } + } + current, err := parseGeminiDocument( + document.exists, + document.filePath, + editor.pack(), + ) + if err != nil { + return nil, err + } + if current.hasHooksContainer && len(current.hooks) == 0 { + if err := editor.remove([]any{"hooks"}); err != nil { + return nil, err + } + } + for _, event := range geminiManagedEvents { + group, exists := additions[event] + if !exists { + continue + } + current, err = parseGeminiDocument( + document.exists, + document.filePath, + editor.pack(), + ) + if err != nil { + return nil, err + } + _, eventExists := current.hooks[event] + switch { + case !current.hasHooksContainer: + err = editor.addProperty(nil, "hooks", map[string]any{event: []any{group}}) + case eventExists: + err = editor.appendArray([]any{"hooks", event}, group) + default: + err = editor.addProperty([]any{"hooks"}, event, []any{group}) + } + if err != nil { + return nil, err + } + } + next := editor.pack() + nextDocument, err := parseGeminiDocument( + document.exists, + document.filePath, + next, + ) + if err != nil { + return nil, err + } + remove := len(additions) == 0 && document.ownedFile && len(nextDocument.root) == 0 + return &geminiRenderedDocument{ + document: document, changed: remove || !bytes.Equal(next, document.raw), + next: next, remove: remove, + }, nil +} + +func appendGeminiPath(base []any, parts ...any) []any { + path := make([]any, 0, len(base)+len(parts)) + path = append(path, base...) + return append(path, parts...) +} diff --git a/cmd/elydora/plugins/gemini_contract.go b/cmd/elydora/plugins/gemini_contract.go new file mode 100644 index 0000000..74e9da7 --- /dev/null +++ b/cmd/elydora/plugins/gemini_contract.go @@ -0,0 +1,253 @@ +package plugins + +import ( + "path/filepath" + "runtime" + "sort" + "strings" +) + +const ( + geminiAgentKey = "gemini" + geminiConfigFile = "settings.json" + geminiGuardScript = "guard.js" + geminiAuditScript = "hook.js" + geminiGuardHookName = "elydora-guard" + geminiAuditHookName = "elydora-audit" + geminiHookTimeout = float64(10_000) + geminiOwnedFileMarker = "// Managed by Elydora" +) + +var geminiManagedEvents = [...]string{"BeforeTool", "AfterTool"} + +type geminiManagedRemoval struct { + event string + groupIndex int + handlerIndexes []int + removeGroup bool +} + +type geminiRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +func buildGeminiGroup(runtimePath, scriptPath, name string) (map[string]any, error) { + command, err := buildGeminiCommand(runtimePath, scriptPath) + if err != nil { + return nil, err + } + return map[string]any{ + "hooks": []any{map[string]any{ + "type": "command", "name": name, "command": command, + "timeout": geminiHookTimeout, + }}, + }, nil +} + +func exactManagedGeminiGroup(group map[string]any) bool { + _, hasHooks := group["hooks"] + return len(group) == 1 && hasHooks +} + +func currentManagedGeminiReference( + handler map[string]any, + scriptName string, + hookName string, +) (*geminiRuntimeReference, error) { + if len(handler) != 4 || handler["type"] != "command" || + handler["name"] != hookName || handler["timeout"] != geminiHookTimeout { + return nil, nil + } + command, ok := handler["command"].(string) + if !ok { + return nil, nil + } + return geminiRuntimeReferenceForCommand(command, scriptName, false) +} + +func legacyManagedGeminiReference( + handler map[string]any, + scriptName string, +) (*geminiRuntimeReference, error) { + if len(handler) != 2 || handler["type"] != "command" { + return nil, nil + } + command, ok := handler["command"].(string) + if !ok { + return nil, nil + } + return geminiRuntimeReferenceForCommand(command, scriptName, true) +} + +func managedGeminiReference( + handler map[string]any, + scriptName string, + hookName string, + includeLegacy bool, +) (*geminiRuntimeReference, error) { + reference, err := currentManagedGeminiReference(handler, scriptName, hookName) + if err != nil || reference != nil || !includeLegacy { + return reference, err + } + return legacyManagedGeminiReference(handler, scriptName) +} + +func managedGeminiHooksEnabled(controls geminiHookControls) bool { + if !controls.enabled { + return false + } + for _, entry := range controls.disabled { + if entry == geminiGuardHookName || entry == geminiAuditHookName { + return false + } + } + return true +} + +func disabledManagedGeminiEntries( + controls geminiHookControls, +) ([]string, error) { + disabled := make([]string, 0) + for _, entry := range controls.disabled { + managed := entry == geminiGuardHookName || entry == geminiAuditHookName + for _, script := range []string{geminiGuardScript, geminiAuditScript} { + reference, err := geminiRuntimeReferenceForCommand(entry, script, true) + if err != nil { + return nil, err + } + managed = managed || reference != nil + } + if managed { + disabled = append(disabled, entry) + } + } + return disabled, nil +} + +func managedGeminiRemovals( + hooks geminiHooks, + agentID string, +) ([]geminiManagedRemoval, error) { + removals := make([]geminiManagedRemoval, 0) + for _, contract := range []struct{ event, script, name string }{ + {"BeforeTool", geminiGuardScript, geminiGuardHookName}, + {"AfterTool", geminiAuditScript, geminiAuditHookName}, + } { + for groupIndex, groupValue := range hooks[contract.event] { + group := groupValue.(map[string]any) + handlers := group["hooks"].([]any) + indexes := make([]int, 0) + for handlerIndex, handlerValue := range handlers { + handler := handlerValue.(map[string]any) + reference, err := managedGeminiReference( + handler, + contract.script, + contract.name, + true, + ) + if err != nil { + return nil, err + } + if reference != nil && + (agentID == "" || sameGeminiAgentID(reference.agentID, agentID)) { + indexes = append(indexes, handlerIndex) + } + } + if len(indexes) > 0 { + removals = append(removals, geminiManagedRemoval{ + contract.event, + groupIndex, + indexes, + exactManagedGeminiGroup(group) && len(indexes) == len(handlers), + }) + } + } + } + return removals, nil +} + +func geminiReferenceKey(agentID string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(agentID) + } + return agentID +} + +func geminiReferencesForEvent( + groups []any, + scriptName string, + hookName string, +) (map[string][]geminiRuntimeReference, error) { + result := map[string][]geminiRuntimeReference{} + for _, groupValue := range groups { + group := groupValue.(map[string]any) + if !exactManagedGeminiGroup(group) { + continue + } + for _, handlerValue := range group["hooks"].([]any) { + reference, err := currentManagedGeminiReference( + handlerValue.(map[string]any), + scriptName, + hookName, + ) + if err != nil { + return nil, err + } + if reference != nil { + key := geminiReferenceKey(reference.agentID) + result[key] = append(result[key], *reference) + } + } + } + return result, nil +} + +func geminiRuntimeContracts( + hooks geminiHooks, +) ([]geminiRuntimeContract, error) { + guards, err := geminiReferencesForEvent( + hooks["BeforeTool"], + geminiGuardScript, + geminiGuardHookName, + ) + if err != nil { + return nil, err + } + audits, err := geminiReferencesForEvent( + hooks["AfterTool"], + geminiAuditScript, + geminiAuditHookName, + ) + 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([]geminiRuntimeContract, 0, len(keys)) + for _, key := range keys { + guard := guards[key] + audit := audits[key] + if len(guard) != 1 || len(audit) != 1 { + continue + } + contracts = append(contracts, geminiRuntimeContract{ + agentID: guard[0].agentID, + guardPath: guard[0].scriptPath, + auditPath: audit[0].scriptPath, + }) + } + return contracts, nil +} + +func geminiRuntimeRoot() (string, error) { + root, err := AgentRuntimeRoot() + if err != nil { + return "", err + } + return filepath.Clean(root), nil +} diff --git a/cmd/elydora/plugins/gemini_installation.go b/cmd/elydora/plugins/gemini_installation.go new file mode 100644 index 0000000..2b99479 --- /dev/null +++ b/cmd/elydora/plugins/gemini_installation.go @@ -0,0 +1,193 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type geminiRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string +} + +func validateGeminiInstallConfig(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 != geminiAgentKey { + return fmt.Errorf( + "Gemini CLI installation requires agent name %s", + geminiAgentKey, + ) + } + 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 geminiAgentPaths(config InstallConfig) (*geminiRuntimePaths, error) { + if err := validateGeminiInstallConfig(config); err != nil { + return nil, err + } + runtimeRoot, err := geminiRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + paths := &geminiRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, geminiGuardScript), + auditPath: filepath.Join(agentDirectory, geminiAuditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameGeminiPath(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) || + !sameGeminiPath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "audit runtime must use the managed Elydora agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightGeminiInstallation( + config InstallConfig, + document *geminiDocument, +) (*geminiRuntimePaths, string, error) { + if document == nil || document.filePath == "" { + return nil, "", fmt.Errorf( + "Gemini CLI installation requires a user settings document", + ) + } + paths, err := geminiAgentPaths(config) + if err != nil { + return nil, "", err + } + if err := validateGeminiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + return nil, "", err + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + if !filepath.IsAbs(nodePath) || !isGeminiNodeExecutable(nodePath) { + return nil, "", fmt.Errorf( + "Gemini CLI hooks require an absolute Node.js executable path", + ) + } + return paths, nodePath, nil +} + +func buildGeminiRuntimeConfig(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": geminiAgentKey, + } + 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 prepareGeminiInstallationChanges( + config InstallConfig, + paths *geminiRuntimePaths, + rendered *geminiRenderedDocument, +) ([]*fileChange, error) { + runtimeConfig, err := buildGeminiRuntimeConfig(config) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + { + paths.guardPath, "Elydora guard runtime", + []byte(generateGuardScript( + geminiAgentKey, + config.AgentID, + "{}\n", + false, + "", + )), + 0700, + }, + {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, + {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, + { + paths.auditPath, "Elydora audit runtime", + []byte(buildHookScriptWithOutput( + geminiAgentKey, + config.AgentID, + "{}\n", + 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 := prepareRenderedGeminiChange(rendered) + if err != nil { + return nil, err + } + return append(changes, documentChange), nil +} diff --git a/cmd/elydora/plugins/gemini_io.go b/cmd/elydora/plugins/gemini_io.go new file mode 100644 index 0000000..422d514 --- /dev/null +++ b/cmd/elydora/plugins/gemini_io.go @@ -0,0 +1,104 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" +) + +func geminiConfigurationDirectory() (string, error) { + configured := os.Getenv("GEMINI_CLI_HOME") + if configured == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + configured = home + } + return filepath.Join(configured, ".gemini"), nil +} + +func geminiSettingsPath() (string, error) { + directory, err := geminiConfigurationDirectory() + if err != nil { + return "", err + } + return filepath.Join(directory, geminiConfigFile), nil +} + +func readGeminiDocument() (*geminiDocument, error) { + filePath, err := geminiSettingsPath() + if err != nil { + return nil, err + } + if _, err := managedPhysicalDirectoryExists( + filepath.Dir(filePath), + "Gemini CLI configuration directory", + ); err != nil { + return nil, err + } + snapshot, err := readManagedFile( + filePath, + "Gemini CLI user settings", + maxManagedSourceBytes, + ) + if err != nil { + return nil, err + } + if snapshot == nil { + return createGeminiDocument(filePath) + } + return parseGeminiDocument(true, filePath, snapshot.contents) +} + +func prepareRenderedGeminiChange( + rendered *geminiRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSourceChange( + rendered.document.filePath, + "Gemini CLI user settings", + rendered.document.raw, + rendered.document.exists, + rendered.next, + 0600, + rendered.remove, + ) +} + +func writeGeminiChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot string, + agentDirectory string, + settingsDirectory string, +) error { + hasChanges := false + for _, change := range changes { + if change != nil { + hasChanges = true + break + } + } + if !hasChanges { + return nil + } + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err + } + } + if err := ensureManagedDirectory( + settingsDirectory, + "Gemini CLI configuration directory", + ); err != nil { + return err + } + return writeChanges(changes, label, rename) +} diff --git a/cmd/elydora/plugins/gemini_official_test.go b/cmd/elydora/plugins/gemini_official_test.go new file mode 100644 index 0000000..7bd3dfd --- /dev/null +++ b/cmd/elydora/plugins/gemini_official_test.go @@ -0,0 +1,70 @@ +package plugins + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func runOfficialGemini( + t *testing.T, + fixture *geminiFixture, + entry string, + args ...string, +) (string, error) { + t.Helper() + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + process := exec.Command(nodePath, append([]string{entry}, args...)...) + process.Dir = fixture.projectDir + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "GEMINI_CLI_HOME="+fixture.geminiHome, + "GEMINI_API_KEY=official-loader-test-key", + "GEMINI_TELEMETRY_ENABLED=false", + "OTEL_SDK_DISABLED=true", + ) + output, runErr := process.CombinedOutput() + return string(output), runErr +} + +func TestGeminiOfficialCLIReadsManagedPair(t *testing.T) { + entry := os.Getenv("ELYDORA_GEMINI_ENTRY") + if entry == "" { + t.Skip("ELYDORA_GEMINI_ENTRY is unset") + } + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + version, err := runOfficialGemini(t, fixture, entry, "--version") + if err != nil || !strings.Contains(version, "0.51.0") { + t.Fatalf("official Gemini version = %q, %v", version, err) + } + output, err := runOfficialGemini( + t, + fixture, + entry, + "--skip-trust", + "--list-extensions", + ) + if err != nil { + t.Fatalf("load official Gemini settings: %v\n%s", err, output) + } + lower := strings.ToLower(output) + for _, failure := range []string{ + "invalid settings", + "settings validation", + "failed to parse", + "hook configuration error", + } { + if strings.Contains(lower, failure) { + t.Fatalf("official Gemini rejected settings: %s", output) + } + } +} diff --git a/cmd/elydora/plugins/gemini_runtime_test.go b/cmd/elydora/plugins/gemini_runtime_test.go new file mode 100644 index 0000000..ec52be9 --- /dev/null +++ b/cmd/elydora/plugins/gemini_runtime_test.go @@ -0,0 +1,224 @@ +package plugins + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func installGeminiRuntimeFixture( + t *testing.T, + status string, +) (*geminiFixture, *codexTestAPI, *httptest.Server) { + t.Helper() + api := &codexTestAPI{status: status} + server := httptest.NewServer(api) + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + 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 Gemini hooks: %v", err) + } + return fixture, api, server +} + +func TestGeminiGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) { + fixture, api, server := installGeminiRuntimeFixture(t, "active") + defer server.Close() + settings := readGeminiTestObject(t, fixture.settingsPath) + guard := geminiTestManagedHandler( + t, + settings, + "BeforeTool", + geminiGuardScript, + geminiGuardHookName, + ) + audit := geminiTestManagedHandler( + t, + settings, + "AfterTool", + geminiAuditScript, + geminiAuditHookName, + ) + guardPayload := geminiOfficialPayload(fixture, "BeforeTool") + if exit, stdout, stderr := runGeminiCommand( + t, + guard["command"].(string), + fixture, + guardPayload, + ); exit != 0 || stdout != "{}\n" || stderr != "" { + t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr) + } + firstPayload := geminiOfficialPayload(fixture, "AfterTool") + firstPayload["mcp_context"] = map[string]any{ + "server_name": "filesystem", + "tool_name": "write_file", + } + firstPayload["original_request_name"] = "write_file" + firstPayload["future_provider_field"] = map[string]any{"preserved": true} + secondPayload := geminiOfficialPayload(fixture, "AfterTool") + secondPayload["tool_response"] = map[string]any{ + "output": "second", + "error": nil, + } + for _, payload := range []map[string]any{firstPayload, secondPayload} { + if exit, stdout, stderr := runGeminiCommand( + t, + audit["command"].(string), + fixture, + payload, + ); exit != 0 || stdout != "{}\n" || stderr != "" { + t.Fatalf("audit result = %d, %q, %q", exit, 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"], firstPayload) || + !reflect.DeepEqual(operations[1]["payload"], secondPayload) { + t.Fatalf("Gemini native operations = %#v", operations) + } + if operations[0]["prev_chain_hash"] != + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" || + operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] { + t.Fatalf("Gemini operation chain = %#v", operations) + } +} + +func TestGeminiGuardUsesOfficialBlockingExitCode(t *testing.T) { + for _, status := range []string{"frozen", "revoked"} { + t.Run(status, func(t *testing.T) { + fixture, _, server := installGeminiRuntimeFixture(t, status) + defer server.Close() + guard := geminiTestManagedHandler( + t, + readGeminiTestObject(t, fixture.settingsPath), + "BeforeTool", + geminiGuardScript, + geminiGuardHookName, + ) + exit, stdout, stderr := runGeminiCommand( + t, + guard["command"].(string), + fixture, + geminiOfficialPayload(fixture, "BeforeTool"), + ) + if exit != 2 || stdout != "" || + !strings.Contains(strings.ToLower(stderr), status) { + t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr) + } + }) + } +} + +func TestGeminiRuntimesKeepFailOpenErrorsObservable(t *testing.T) { + fixture, api, server := installGeminiRuntimeFixture(t, "active") + settings := readGeminiTestObject(t, fixture.settingsPath) + guardCommand := geminiTestManagedHandler( + t, + settings, + "BeforeTool", + geminiGuardScript, + geminiGuardHookName, + )["command"].(string) + auditCommand := geminiTestManagedHandler( + t, + settings, + "AfterTool", + geminiAuditScript, + geminiAuditHookName, + )["command"].(string) + invalidExit, invalidOutput, invalidError := runGeminiRawCommand( + t, + guardCommand, + fixture, + []byte("{ malformed"), + ) + if invalidExit != 0 || invalidOutput != "{}\n" || + !strings.Contains(invalidError, "invalid JSON") { + t.Fatalf( + "invalid guard result = %d, %q, %q", + invalidExit, + invalidOutput, + invalidError, + ) + } + api.mu.Lock() + api.postStatus = http.StatusServiceUnavailable + api.mu.Unlock() + auditExit, auditOutput, auditError := runGeminiCommand( + t, + auditCommand, + fixture, + geminiOfficialPayload(fixture, "AfterTool"), + ) + server.Close() + log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log")) + if auditExit != 0 || auditOutput != "{}\n" || auditError != "" || + err != nil || !strings.Contains(string(log), "HTTP 503") { + t.Fatalf( + "audit failure = %d, %q, %q, log=%q, err=%v", + auditExit, + auditOutput, + auditError, + log, + err, + ) + } +} + +func TestGeminiRuntimeArtifactsUsePrivateModesOnPOSIX(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX mode bits are not authoritative on Windows") + } + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + for _, item := range []struct { + path string + mode os.FileMode + }{ + {fixture.runtimeConfig, 0600}, + {fixture.privateKey, 0600}, + {fixture.guardPath, 0700}, + {fixture.hookPath, 0700}, + {fixture.settingsPath, 0600}, + } { + info, err := os.Stat(item.path) + if err != nil || info.Mode().Perm() != item.mode { + t.Fatalf("mode for %s = %v, %v", item.path, info, err) + } + } +} + +func TestGeminiRuntimeOutputIsValidJSON(t *testing.T) { + fixture, _, server := installGeminiRuntimeFixture(t, "active") + defer server.Close() + guard := geminiTestManagedHandler( + t, + readGeminiTestObject(t, fixture.settingsPath), + "BeforeTool", + geminiGuardScript, + geminiGuardHookName, + ) + _, stdout, _ := runGeminiCommand( + t, + guard["command"].(string), + fixture, + geminiOfficialPayload(fixture, "BeforeTool"), + ) + var response map[string]any + if err := json.Unmarshal([]byte(stdout), &response); err != nil || len(response) != 0 { + t.Fatalf("Gemini response = %q, %v", stdout, err) + } +} diff --git a/cmd/elydora/plugins/gemini_schema.go b/cmd/elydora/plugins/gemini_schema.go new file mode 100644 index 0000000..37f6359 --- /dev/null +++ b/cmd/elydora/plugins/gemini_schema.go @@ -0,0 +1,208 @@ +package plugins + +import ( + "fmt" + "math" +) + +var geminiKnownEvents = map[string]bool{ + "BeforeTool": true, "AfterTool": true, "BeforeAgent": true, + "Notification": true, "AfterAgent": true, "SessionStart": true, + "SessionEnd": true, "PreCompress": true, "BeforeModel": true, + "AfterModel": true, "BeforeToolSelection": true, +} + +type geminiHooks map[string][]any + +type geminiHookControls struct { + enabled bool + disabled []string +} + +func optionalGeminiString(value map[string]any, field, label string) error { + if item, exists := value[field]; exists { + if _, ok := item.(string); !ok { + return fmt.Errorf(`%s field %q must be a string`, label, field) + } + } + return nil +} + +func validateGeminiHandler( + value any, + event string, + groupIndex int, + handlerIndex int, +) error { + label := fmt.Sprintf( + "Gemini CLI settings handler hooks.%s[%d].hooks[%d]", + event, + groupIndex, + handlerIndex, + ) + handler, ok := value.(map[string]any) + if !ok || handler == nil { + return fmt.Errorf("%s must be an object", label) + } + if handler["type"] != "command" { + return fmt.Errorf( + `%s has unsupported type %q`, + label, + fmt.Sprint(handler["type"]), + ) + } + for _, field := range []string{"name", "description", "source"} { + if err := optionalGeminiString(handler, field, label); err != nil { + return err + } + } + if timeout, exists := handler["timeout"]; exists { + number, ok := timeout.(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) || number < 0 { + return fmt.Errorf( + "%s timeout must be a non-negative finite number", + label, + ) + } + } + if environment, exists := handler["env"]; exists { + entries, ok := environment.(map[string]any) + if !ok { + return fmt.Errorf("%s env must map names to strings", label) + } + for _, entry := range entries { + if _, ok := entry.(string); !ok { + return fmt.Errorf("%s env must map names to strings", label) + } + } + } + command, ok := handler["command"].(string) + if !ok || command == "" { + return fmt.Errorf("%s requires a non-empty command", label) + } + return nil +} + +func validateGeminiGroup(value any, event string, groupIndex int) error { + label := fmt.Sprintf( + "Gemini CLI settings group hooks.%s[%d]", + event, + groupIndex, + ) + group, ok := value.(map[string]any) + if !ok || group == nil { + return fmt.Errorf("%s must be an object", label) + } + if matcher, exists := group["matcher"]; exists { + if _, ok := matcher.(string); !ok { + return fmt.Errorf("%s matcher must be a string", label) + } + } + if sequential, exists := group["sequential"]; exists { + if _, ok := sequential.(bool); !ok { + return fmt.Errorf("%s sequential must be a boolean", label) + } + } + handlers, ok := group["hooks"].([]any) + if !ok { + return fmt.Errorf("%s must contain a hooks array", label) + } + for handlerIndex, handler := range handlers { + if err := validateGeminiHandler( + handler, + event, + groupIndex, + handlerIndex, + ); err != nil { + return err + } + } + return nil +} + +func readGeminiHooks(root map[string]any) (geminiHooks, error) { + value, exists := root["hooks"] + if !exists { + return geminiHooks{}, nil + } + object, ok := value.(map[string]any) + if !ok || object == nil { + return nil, fmt.Errorf(`Gemini CLI settings field "hooks" must be an object`) + } + hooks := make(geminiHooks, len(object)) + for event, groupsValue := range object { + groups, ok := groupsValue.([]any) + if !ok { + return nil, fmt.Errorf( + `Gemini CLI settings field "hooks.%s" must be an array`, + event, + ) + } + if geminiKnownEvents[event] { + for groupIndex, group := range groups { + if err := validateGeminiGroup(group, event, groupIndex); err != nil { + return nil, err + } + } + } + hooks[event] = append([]any(nil), groups...) + } + return hooks, nil +} + +func readGeminiHookControls(root map[string]any) (geminiHookControls, error) { + value, exists := root["hooksConfig"] + if !exists { + return geminiHookControls{enabled: true}, nil + } + object, ok := value.(map[string]any) + if !ok || object == nil { + return geminiHookControls{}, fmt.Errorf( + `Gemini CLI settings field "hooksConfig" must be an object`, + ) + } + for field := range object { + if field != "enabled" && field != "disabled" && field != "notifications" { + return geminiHookControls{}, fmt.Errorf( + `Gemini CLI settings field "hooksConfig" contains unsupported field %q`, + field, + ) + } + } + enabled := true + if value, exists := object["enabled"]; exists { + flag, ok := value.(bool) + if !ok { + return geminiHookControls{}, fmt.Errorf( + `Gemini CLI settings field "hooksConfig.enabled" must be a boolean`, + ) + } + enabled = flag + } + if value, exists := object["notifications"]; exists { + if _, ok := value.(bool); !ok { + return geminiHookControls{}, fmt.Errorf( + `Gemini CLI settings field "hooksConfig.notifications" must be a boolean`, + ) + } + } + disabled := []string{} + if value, exists := object["disabled"]; exists { + items, ok := value.([]any) + if !ok { + return geminiHookControls{}, fmt.Errorf( + `Gemini CLI settings field "hooksConfig.disabled" must be an array of strings`, + ) + } + for _, item := range items { + entry, ok := item.(string) + if !ok { + return geminiHookControls{}, fmt.Errorf( + `Gemini CLI settings field "hooksConfig.disabled" must be an array of strings`, + ) + } + disabled = append(disabled, entry) + } + } + return geminiHookControls{enabled: enabled, disabled: disabled}, nil +} diff --git a/cmd/elydora/plugins/gemini_security_test.go b/cmd/elydora/plugins/gemini_security_test.go new file mode 100644 index 0000000..d740c79 --- /dev/null +++ b/cmd/elydora/plugins/gemini_security_test.go @@ -0,0 +1,266 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestGeminiRejectsLinkedSettingsBeforeRuntimeWrites(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + target := filepath.Join(filepath.Dir(fixture.homeDir), "settings-target.json") + source := []byte("{\"owner\":\"protected\"}\n") + if err := os.MkdirAll(filepath.Dir(fixture.settingsPath), 0700); err != nil { + t.Fatalf("create Gemini settings directory: %v", err) + } + if err := os.WriteFile(target, source, 0600); err != nil { + t.Fatalf("write settings target: %v", err) + } + geminiSymlinkOrSkip(t, target, fixture.settingsPath) + 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) + } + assertNoGeminiRuntimeWrites(t, fixture) +} + +func TestGeminiRejectsLinkedSettingsAndRuntimeDirectories(t *testing.T) { + for _, kind := range []string{"settings", "runtime"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + 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 + if kind == "settings" { + linkPath = filepath.Dir(fixture.settingsPath) + if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil { + t.Fatalf("create settings parent: %v", err) + } + } else { + linkPath = filepath.Join(fixture.homeDir, ".elydora") + if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil { + t.Fatalf("create runtime parent: %v", err) + } + } + geminiSymlinkOrSkip(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.settingsPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings exist after runtime rejection: %v", err) + } + } else { + assertNoGeminiRuntimeWrites(t, fixture) + } + }) + } +} + +func TestGeminiRejectsLinkedRuntimeArtifacts(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 := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini 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) + geminiSymlinkOrSkip(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 TestGeminiRejectsOrphanedAndMismatchedRuntimeIdentity(t *testing.T) { + for _, kind := range []string{"orphaned", "mismatched"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + 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":"gemini","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.settingsPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings exist after identity rejection: %v", err) + } + }) + } +} + +func TestGeminiValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { + for _, testCase := range []struct { + name string + edit func(*geminiFixture) + want string + }{ + {"agent name", func(f *geminiFixture) { f.config.AgentName = "codex" }, "requires agent name gemini"}, + {"organization", func(f *geminiFixture) { f.config.OrgID = " " }, "organization ID is required"}, + {"key ID", func(f *geminiFixture) { f.config.KID = "\t" }, "key ID is required"}, + {"token", func(f *geminiFixture) { f.config.Token = " " }, "token must contain"}, + {"private key", func(f *geminiFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"}, + {"base URL", func(f *geminiFixture) { + f.config.BaseURL = "https://api.elydora.com/path?token=secret" + }, "query parameters"}, + {"guard path", func(f *geminiFixture) { + f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") + }, "managed Elydora agent directory"}, + {"audit path", func(f *geminiFixture) { + f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") + }, "managed Elydora agent directory"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + 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.settingsPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings exist after input rejection: %v", err) + } + assertNoGeminiRuntimeWrites(t, fixture) + }) + } +} + +func TestGeminiStatusSurfacesInvalidRuntimeMetadata(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":"gemini","extra":true}`, "unsupported field"}, + {"identity", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"gemini"}`, "identity does not match"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini 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 TestGeminiStatusRequiresEveryRuntimeFile(t *testing.T) { + for _, name := range []string{"config.json", "private.key", "guard.js", "hook.js"} { + t.Run("missing "+name, func(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini 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) + } + }) + } +} + +func TestGeminiOwnershipRequiresExactHandlerFields(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + guard := legacyGeminiCommand(fixture.guardPath) + audit := legacyGeminiCommand(fixture.hookPath) + writeGeminiTestObject(t, fixture.settingsPath, map[string]any{ + "hooks": map[string]any{ + "BeforeTool": []any{map[string]any{ + "label": "shared", + "hooks": []any{map[string]any{ + "type": "command", "command": guard, + }}, + }}, + "AfterTool": []any{map[string]any{ + "hooks": []any{map[string]any{ + "type": "command", "command": audit, "label": "user", + }}, + }}, + }, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + if err := fixture.plugin.Uninstall(geminiTestAgentID); err != nil { + t.Fatalf("uninstall Gemini hooks: %v", err) + } + remaining := readGeminiTestObject(t, fixture.settingsPath) + hooks := requireObject(t, remaining["hooks"]) + before := requireArray(t, hooks["BeforeTool"]) + after := requireArray(t, hooks["AfterTool"]) + if len(before) != 1 || requireObject(t, before[0])["label"] != "shared" || + len(requireArray(t, requireObject(t, before[0])["hooks"])) != 0 || + len(after) != 1 || len(requireArray(t, requireObject(t, after[0])["hooks"])) != 1 { + t.Fatalf("ownership lookalikes changed: %#v", hooks) + } +} diff --git a/cmd/elydora/plugins/gemini_status.go b/cmd/elydora/plugins/gemini_status.go new file mode 100644 index 0000000..1650cf6 --- /dev/null +++ b/cmd/elydora/plugins/gemini_status.go @@ -0,0 +1,231 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +func readGeminiRuntimeConfig(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 requireGeminiRuntimeString( + 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 validateGeminiRuntimeConfig( + 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 := requireGeminiRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireGeminiRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireGeminiRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameGeminiAgentID(agentID, expectedAgentID) || + config["agent_name"] != geminiAgentKey { + return fmt.Errorf( + "elydora runtime identity does not match Gemini CLI hooks: %s", + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireGeminiRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireGeminiRuntimeString(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 validateGeminiRuntimeIdentity(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 := readGeminiRuntimeConfig(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, geminiGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, geminiAuditScript), "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"] != geminiAgentKey || + !sameGeminiAgentID(configuredID, agentID) { + return fmt.Errorf( + "elydora runtime config identity does not match Gemini CLI agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func geminiRuntimeContractExists(contract geminiRuntimeContract) (bool, error) { + runtimeRoot, err := geminiRuntimeRoot() + if err != nil { + return false, err + } + agentDirectory := filepath.Dir(contract.guardPath) + if !sameGeminiPath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameGeminiPath( + contract.auditPath, + filepath.Join(agentDirectory, geminiAuditScript), + ) { + 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 := readGeminiRuntimeConfig(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 := validateGeminiRuntimeConfig(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 geminiRuntimeFilesExist(contracts []geminiRuntimeContract) (bool, error) { + for _, contract := range contracts { + exists, err := geminiRuntimeContractExists(contract) + if err != nil { + return false, err + } + if exists { + return true, nil + } + } + return false, nil +} diff --git a/cmd/elydora/plugins/gemini_test.go b/cmd/elydora/plugins/gemini_test.go new file mode 100644 index 0000000..919e48b --- /dev/null +++ b/cmd/elydora/plugins/gemini_test.go @@ -0,0 +1,436 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestGeminiRegistryFactoryAndRuntimeOwnership(t *testing.T) { + entry := SupportedAgents[geminiAgentKey] + if entry.Name != "Gemini CLI" || entry.ConfigDir != "~/.gemini" || + entry.ConfigFile != geminiConfigFile { + t.Fatalf("Gemini registry entry = %#v", entry) + } + plugin, ok := NewPlugin(geminiAgentKey).(*GeminiPlugin) + if !ok || !plugin.ManagesGuardRuntime() { + t.Fatalf("Gemini plugin = %#v", NewPlugin(geminiAgentKey)) + } +} + +func TestGeminiCommandRoundTripAndRejectsInjection(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + command, err := buildGeminiCommand(nodePath, fixture.guardPath) + if err != nil { + t.Fatalf("build Gemini command: %v", err) + } + executable, script, ok := parseGeminiCommand(command) + if !ok || !sameGeminiPath(executable, nodePath) || + !sameGeminiPath(script, fixture.guardPath) { + t.Fatalf("parsed Gemini command = %q, %q, %v", executable, script, ok) + } + if runtime.GOOS == "windows" && (strings.Contains(command, "%GEMINI_CWD%") || + strings.Contains(command, "$GEMINI_CWD")) { + t.Fatalf("Windows Gemini command exposes expandable path text: %q", command) + } + if _, err := buildGeminiCommand("node", fixture.guardPath); err == nil { + t.Fatal("accepted a relative Gemini runtime path") + } + if _, err := buildGeminiCommand(nodePath, "guard.js"); err == nil { + t.Fatal("accepted a relative Gemini script path") + } + for _, invalid := range []string{ + command + " --inspect", + command + "\nexit 0", + "node " + fixture.guardPath, + } { + if _, _, ok := parseGeminiCommand(invalid); ok { + t.Fatalf("accepted non-canonical command %q", invalid) + } + } +} + +func TestGeminiInstallPreservesJSONCAndIsIdempotent(t *testing.T) { + existing := strings.Join([]string{ + "{", + " // Keep this user preference.", + ` "theme": "GitHub",`, + ` "hooks": {`, + ` "FutureEvent": [null],`, + ` "BeforeTool": [{ "matcher": "read_file", "hooks": [{ "type": "command", "command": "user-hook" }] }]`, + " }", + "}", + "", + }, "\r\n") + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: &existing}, + ) + projectSettings := filepath.Join( + fixture.projectDir, + ".gemini", + geminiConfigFile, + ) + systemSettings := filepath.Join(filepath.Dir(fixture.homeDir), "system.json") + for path, source := range map[string]string{ + projectSettings: `{ "owner": "project" }` + "\n", + systemSettings: `{ "owner": "system" }` + "\n", + } { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create source directory: %v", err) + } + if err := os.WriteFile(path, []byte(source), 0600); err != nil { + t.Fatalf("write integration source: %v", err) + } + } + t.Setenv("GEMINI_CLI_SYSTEM_SETTINGS_PATH", systemSettings) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + first, err := os.ReadFile(fixture.settingsPath) + if err != nil { + t.Fatalf("read first Gemini settings: %v", err) + } + if !strings.Contains(string(first), "Keep this user preference") || + !strings.Contains(string(first), "\r\n") { + t.Fatalf("JSONC formatting changed: %q", first) + } + settings := readGeminiTestObject(t, fixture.settingsPath) + if settings["theme"] != "GitHub" || !reflect.DeepEqual( + requireObject(t, settings["hooks"])["FutureEvent"], + []any{nil}, + ) { + t.Fatalf("user settings changed: %#v", settings) + } + beforeTool := requireArray(t, requireObject(t, settings["hooks"])["BeforeTool"]) + if requireObject(t, requireArray( + t, + requireObject(t, beforeTool[0])["hooks"], + )[0])["command"] != "user-hook" { + t.Fatalf("user hook changed: %#v", beforeTool) + } + requireStrictGeminiPair(t, settings) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat Gemini install: %v", err) + } + second, err := os.ReadFile(fixture.settingsPath) + if err != nil || string(second) != string(first) { + t.Fatalf("repeat install changed settings: %v", err) + } + for path, want := range map[string]string{ + projectSettings: `{ "owner": "project" }` + "\n", + systemSettings: `{ "owner": "system" }` + "\n", + } { + actual, readErr := os.ReadFile(path) + if readErr != nil || string(actual) != want { + t.Fatalf("integration source changed at %s: %q, %v", path, actual, readErr) + } + } + for _, path := range []string{ + fixture.guardPath, + fixture.hookPath, + fixture.runtimeConfig, + fixture.privateKey, + } { + info, statErr := os.Lstat(path) + if statErr != nil || !info.Mode().IsRegular() { + t.Fatalf("managed runtime %s = %v, %v", path, info, statErr) + } + } + assertNoGeminiTransactionArtifacts(t, fixture.homeDir) +} + +func TestGeminiHomeResolutionMatchesOfficialSemantics(t *testing.T) { + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{useDefaultHome: true}, + ) + want := filepath.Join(fixture.homeDir, ".gemini") + if actual, err := geminiConfigurationDirectory(); err != nil || actual != want { + t.Fatalf("empty override directory = %q, %v; want %q", actual, err, want) + } + previous, err := os.Getwd() + if err != nil { + t.Fatalf("read working directory: %v", err) + } + if err := os.Chdir(fixture.projectDir); err != nil { + t.Fatalf("change working directory: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + for _, testCase := range []struct{ value, expected string }{ + {"relative gemini", filepath.Join("relative gemini", ".gemini")}, + {"~", filepath.Join("~", ".gemini")}, + } { + t.Setenv("GEMINI_CLI_HOME", testCase.value) + actual, resolveErr := geminiConfigurationDirectory() + if resolveErr != nil || actual != testCase.expected { + t.Fatalf("override %q = %q, %v; want %q", testCase.value, actual, resolveErr, testCase.expected) + } + } +} + +func TestGeminiInstallAppendsToExistingEmptyManagedEvents(t *testing.T) { + source := `{"hooks":{"BeforeTool":[],"AfterTool":[]}}` + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: &source}, + ) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks into empty events: %v", err) + } + settings := readGeminiTestObject(t, fixture.settingsPath) + requireStrictGeminiPair(t, settings) + hooks := requireObject(t, settings["hooks"]) + if len(requireArray(t, hooks["BeforeTool"])) != 1 || + len(requireArray(t, hooks["AfterTool"])) != 1 { + t.Fatalf("managed event groups = %#v", hooks) + } +} + +func TestGeminiRejectsInvalidSettingsBeforeRuntimeWrites(t *testing.T) { + for _, testCase := range []struct{ name, raw, want string }{ + {"syntax", "{ malformed", "parse Gemini CLI user settings"}, + {"root", "[]", "must contain a JSON object"}, + {"trailing comma", `{ "theme": true, }`, "trailing comma"}, + {"duplicate", `{ "hooks": {}, "hooks": {} }`, "duplicate key"}, + {"hooks", `{ "hooks": null }`, `field "hooks" must be an object`}, + {"event", `{ "hooks": { "BeforeTool": null } }`, "must be an array"}, + {"group", `{ "hooks": { "BeforeTool": [null] } }`, "must be an object"}, + {"matcher", `{ "hooks": { "BeforeTool": [{ "matcher": 1, "hooks": [] }] } }`, "matcher must be a string"}, + {"sequential", `{ "hooks": { "BeforeTool": [{ "sequential": 1, "hooks": [] }] } }`, "sequential must be a boolean"}, + {"missing handlers", `{ "hooks": { "BeforeTool": [{}] } }`, "must contain a hooks array"}, + {"handler", `{ "hooks": { "BeforeTool": [{ "hooks": [null] }] } }`, "must be an object"}, + {"type", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "http" }] }] } }`, "unsupported type"}, + {"command", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "" }] }] } }`, "non-empty command"}, + {"timeout", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "x", "timeout": -1 }] }] } }`, "non-negative finite number"}, + {"environment", `{ "hooks": { "BeforeTool": [{ "hooks": [{ "type": "command", "command": "x", "env": { "A": 1 } }] }] } }`, "env must map names to strings"}, + {"controls", `{ "hooksConfig": null }`, `hooksConfig" must be an object`}, + {"control field", `{ "hooksConfig": { "future": true } }`, "unsupported field"}, + {"enabled", `{ "hooksConfig": { "enabled": "yes" } }`, "must be a boolean"}, + {"disabled", `{ "hooksConfig": { "disabled": [1] } }`, "array of strings"}, + {"notifications", `{ "hooksConfig": { "notifications": 1 } }`, "must be a boolean"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: geminiString(testCase.raw)}, + ) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v, want %q", err, testCase.want) + } + raw, readErr := os.ReadFile(fixture.settingsPath) + if readErr != nil || string(raw) != testCase.raw { + t.Fatalf("original settings changed: %q, %v", raw, readErr) + } + assertNoGeminiRuntimeWrites(t, fixture) + }) + } +} + +func TestGeminiRespectsOfficialHookControls(t *testing.T) { + for _, testCase := range []struct{ raw, want string }{ + {`{"hooksConfig":{"enabled":false}}`, "hooksConfig.enabled"}, + {`{"hooksConfig":{"disabled":["elydora-guard"]}}`, geminiGuardHookName}, + {`{"hooksConfig":{"disabled":["elydora-audit"]}}`, geminiAuditHookName}, + } { + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: geminiString(testCase.raw)}, + ) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("hook control error = %v, want %q", err, testCase.want) + } + assertNoGeminiRuntimeWrites(t, fixture) + } + legacy := prepareGeminiFixture(t, geminiFixtureOptions{}) + writeGeminiTestObject(t, legacy.settingsPath, map[string]any{ + "hooksConfig": map[string]any{ + "disabled": []any{legacyGeminiCommand(legacy.guardPath)}, + }, + }) + err := legacy.plugin.Install(legacy.config) + if err == nil || !strings.Contains(err.Error(), "hooksConfig.disabled") { + t.Fatalf("legacy disabled command error = %v", err) + } + assertNoGeminiRuntimeWrites(t, legacy) +} + +func TestGeminiMigratesLegacyHandlersAndPreservesLookalikes(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + legacyGuard := legacyGeminiCommand(fixture.guardPath) + legacyAudit := legacyGeminiCommand(fixture.hookPath) + lookalike := legacyGuard + " --inspect" + writeGeminiTestObject(t, fixture.settingsPath, map[string]any{ + "hooks": map[string]any{ + "BeforeTool": []any{ + map[string]any{"hooks": []any{map[string]any{"type": "command", "command": legacyGuard}}}, + map[string]any{"hooks": []any{map[string]any{"type": "command", "command": lookalike}}}, + }, + "AfterTool": []any{ + map[string]any{"hooks": []any{map[string]any{"type": "command", "command": legacyAudit}}}, + }, + }, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("migrate Gemini hooks: %v", err) + } + settings := readGeminiTestObject(t, fixture.settingsPath) + requireStrictGeminiPair(t, settings) + raw, _ := os.ReadFile(fixture.settingsPath) + if !strings.Contains(string(raw), "--inspect") { + t.Fatalf("ownership lookalike was removed: %s", raw) + } + hooks := requireObject(t, settings["hooks"]) + groups := requireArray(t, hooks["BeforeTool"]) + managed := requireObject(t, groups[len(groups)-1]) + managed["hooks"] = append( + requireArray(t, managed["hooks"]), + map[string]any{"type": "command", "command": "user-command"}, + ) + writeGeminiTestObject(t, fixture.settingsPath, settings) + if err := fixture.plugin.Uninstall(geminiTestAgentID); err != nil { + t.Fatalf("uninstall Gemini hooks: %v", err) + } + remaining := readGeminiTestObject(t, fixture.settingsPath) + remainingHooks := requireObject(t, remaining["hooks"]) + if _, exists := remainingHooks["AfterTool"]; exists { + t.Fatalf("managed audit hook remains: %#v", remainingHooks) + } + commands := []string{} + for _, groupValue := range requireArray(t, remainingHooks["BeforeTool"]) { + 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 TestGeminiUninstallPreservesUserSettingsAndRemovesOwnedFile(t *testing.T) { + userSource := `{"theme":"GitHub","hooks":{"Notification":[]}}` + user := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: &userSource}, + ) + if err := user.plugin.Install(user.config); err != nil { + t.Fatalf("install user Gemini hooks: %v", err) + } + if err := user.plugin.Uninstall(geminiTestAgentID); err != nil { + t.Fatalf("uninstall user Gemini hooks: %v", err) + } + remaining := readGeminiTestObject(t, user.settingsPath) + if remaining["theme"] != "GitHub" || !reflect.DeepEqual( + requireObject(t, remaining["hooks"])["Notification"], + []any{}, + ) { + t.Fatalf("user settings changed: %#v", remaining) + } + owned := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := owned.plugin.Install(owned.config); err != nil { + t.Fatalf("install owned Gemini hooks: %v", err) + } + if err := owned.plugin.Uninstall(geminiTestAgentID); err != nil { + t.Fatalf("uninstall owned Gemini hooks: %v", err) + } + if _, err := os.Lstat(owned.settingsPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("owned Gemini settings remain: %v", err) + } +} + +func TestGeminiStatusRequiresExactPairControlsAndRuntimeIdentity(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + assertInstalled := func(want bool) { + t.Helper() + status, err := fixture.plugin.Status() + if err != nil || status.Installed != want { + t.Fatalf("Gemini status = %#v, %v; want installed %v", status, err, want) + } + } + assertInstalled(true) + settings := readGeminiTestObject(t, fixture.settingsPath) + hooks := requireObject(t, settings["hooks"]) + hooks["AfterTool"] = append( + requireArray(t, hooks["AfterTool"]), + requireArray(t, hooks["AfterTool"])[0], + ) + writeGeminiTestObject(t, fixture.settingsPath, settings) + assertInstalled(false) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair duplicate Gemini hooks: %v", err) + } + settings = readGeminiTestObject(t, fixture.settingsPath) + settings["hooksConfig"] = map[string]any{"disabled": []any{geminiAuditHookName}} + writeGeminiTestObject(t, fixture.settingsPath, settings) + status, err := fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("disabled Gemini status = %#v, %v", status, err) + } + settings["hooksConfig"] = map[string]any{"disabled": []any{}} + writeGeminiTestObject(t, fixture.settingsPath, settings) + 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(), "private key") { + t.Fatalf("invalid private key status error = %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Gemini runtime: %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 TestGeminiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + fixture.config.Token = "" + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks without token: %v", err) + } + config := readGeminiTestObject(t, fixture.runtimeConfig) + if _, exists := config["token"]; exists { + t.Fatalf("empty optional token was persisted: %#v", config) + } +} + +func TestGeminiAgentIDMatchingIsPlatformCorrect(t *testing.T) { + if runtime.GOOS == "windows" { + if !sameGeminiAgentID("AGENT-1", "agent-1") { + t.Fatal("Windows Gemini agent IDs should compare case-insensitively") + } + return + } + if sameGeminiAgentID("AGENT-1", "agent-1") { + t.Fatal("POSIX Gemini agent IDs should preserve case") + } +} diff --git a/cmd/elydora/plugins/gemini_test_helpers_test.go b/cmd/elydora/plugins/gemini_test_helpers_test.go new file mode 100644 index 0000000..d9297eb --- /dev/null +++ b/cmd/elydora/plugins/gemini_test_helpers_test.go @@ -0,0 +1,330 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +const ( + geminiTestAgentID = "agent-1" + geminiPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" +) + +type geminiFixtureOptions struct { + existingRaw *string + useDefaultHome bool +} + +type geminiFixture struct { + plugin *GeminiPlugin + config InstallConfig + homeDir string + projectDir string + geminiHome string + agentDir string + settingsPath string + guardPath string + hookPath string + runtimeConfig string + privateKey string +} + +func geminiString(value string) *string { + return &value +} + +func prepareGeminiFixture( + t *testing.T, + options geminiFixtureOptions, +) *geminiFixture { + t.Helper() + root := t.TempDir() + homeDir := filepath.Join( + root, + "home with 'quote $GEMINI_CWD %GEMINI_CWD%", + ) + projectDir := filepath.Join(root, "project with spaces") + geminiHome := filepath.Join(homeDir, "custom gemini home") + if options.useDefaultHome { + geminiHome = homeDir + } + settingsPath := filepath.Join(geminiHome, ".gemini", geminiConfigFile) + agentDir := filepath.Join(homeDir, ".elydora", geminiTestAgentID) + if err := os.MkdirAll(projectDir, 0700); err != nil { + t.Fatalf("create project directory: %v", err) + } + if options.existingRaw != nil { + if err := os.MkdirAll(filepath.Dir(settingsPath), 0700); err != nil { + t.Fatalf("create Gemini settings directory: %v", err) + } + if err := os.WriteFile( + settingsPath, + []byte(*options.existingRaw), + 0600, + ); err != nil { + t.Fatalf("write Gemini settings: %v", err) + } + } + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-fragment")) + if options.useDefaultHome { + t.Setenv("GEMINI_CLI_HOME", "") + } else { + t.Setenv("GEMINI_CLI_HOME", geminiHome) + } + guardPath := filepath.Join(agentDir, geminiGuardScript) + return &geminiFixture{ + plugin: &GeminiPlugin{}, + config: InstallConfig{ + AgentName: geminiAgentKey, OrgID: "org-1", + AgentID: geminiTestAgentID, PrivateKey: geminiPrivateKey, + KID: "kid-1", Token: "token-1", + BaseURL: "http://127.0.0.1:9", GuardScriptPath: guardPath, + }, + homeDir: homeDir, projectDir: projectDir, geminiHome: geminiHome, + agentDir: agentDir, settingsPath: settingsPath, guardPath: guardPath, + hookPath: filepath.Join(agentDir, geminiAuditScript), + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + } +} + +func readGeminiTestObject(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + value, err := decodeJSONCObject(raw, "Gemini test settings", false) + if err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +func writeGeminiTestObject(t *testing.T, path string, value map[string]any) { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("marshal Gemini settings: %v", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create Gemini settings directory: %v", err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatalf("write Gemini settings: %v", err) + } +} + +func geminiTestManagedHandler( + t *testing.T, + settings map[string]any, + event string, + scriptName string, + hookName string, +) map[string]any { + t.Helper() + hooks := requireObject(t, settings["hooks"]) + for _, groupValue := range requireArray(t, hooks[event]) { + group := requireObject(t, groupValue) + for _, handlerValue := range requireArray(t, group["hooks"]) { + handler := requireObject(t, handlerValue) + reference, err := currentManagedGeminiReference( + handler, + scriptName, + hookName, + ) + if err != nil { + t.Fatalf("inspect managed %s handler: %v", event, err) + } + if reference != nil { + return handler + } + } + } + t.Fatalf("%s handler for %s not found", event, scriptName) + return nil +} + +func requireStrictGeminiPair(t *testing.T, settings map[string]any) { + t.Helper() + hooks := requireObject(t, settings["hooks"]) + for _, item := range []struct { + event, script, name string + }{ + {"BeforeTool", geminiGuardScript, geminiGuardHookName}, + {"AfterTool", geminiAuditScript, geminiAuditHookName}, + } { + handler := geminiTestManagedHandler( + t, + settings, + item.event, + item.script, + item.name, + ) + want := map[string]any{ + "type": "command", "name": item.name, + "command": handler["command"], "timeout": float64(10_000), + } + if !reflect.DeepEqual(handler, want) { + t.Fatalf("%s handler = %#v", item.event, handler) + } + managed := 0 + for _, groupValue := range requireArray(t, hooks[item.event]) { + group := requireObject(t, groupValue) + for _, handlerValue := range requireArray(t, group["hooks"]) { + reference, err := currentManagedGeminiReference( + requireObject(t, handlerValue), + item.script, + item.name, + ) + if err != nil { + t.Fatalf("inspect %s contract: %v", item.event, err) + } + if reference != nil { + managed++ + if len(group) != 1 || len(requireArray(t, group["hooks"])) != 1 { + t.Fatalf("%s managed group has extra fields: %#v", item.event, group) + } + } + } + } + if managed != 1 { + t.Fatalf("%s managed handler count = %d", item.event, managed) + } + command := handler["command"].(string) + if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") { + t.Fatalf("Windows Gemini command is not encoded PowerShell: %q", command) + } + } +} + +func geminiOfficialPayload(fixture *geminiFixture, event string) map[string]any { + payload := map[string]any{ + "session_id": "session-1", "transcript_path": filepath.Join( + fixture.projectDir, + "transcript.jsonl", + ), + "cwd": fixture.projectDir, "hook_event_name": event, + "timestamp": "2026-07-19T00:00:00.000Z", + "tool_name": "run_shell_command", + "tool_input": map[string]any{"command": "echo test"}, + } + if event == "AfterTool" { + payload["tool_response"] = map[string]any{"output": "test", "error": nil} + } + return payload +} + +func runGeminiCommand( + t *testing.T, + command string, + fixture *geminiFixture, + payload map[string]any, +) (int, string, string) { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Gemini payload: %v", err) + } + return runGeminiRawCommand(t, command, fixture, encoded) +} + +func runGeminiRawCommand( + t *testing.T, + command string, + fixture *geminiFixture, + encoded []byte, +) (int, string, string) { + t.Helper() + var process *exec.Cmd + if runtime.GOOS == "windows" { + process = exec.Command( + codexPowerShellPath(), + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + command+"; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }", + ) + } else { + process = exec.Command("sh", "-c", command) + } + process.Dir = fixture.projectDir + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "GEMINI_CLI_HOME="+fixture.geminiHome, + "ELYDORA_HOOK_PATH=injected-command-fragment", + ) + process.Stdin = bytes.NewReader(encoded) + var stdout, stderr bytes.Buffer + process.Stdout = &stdout + process.Stderr = &stderr + err := process.Run() + if err == nil { + return 0, stdout.String(), stderr.String() + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode(), stdout.String(), stderr.String() + } + t.Fatalf("run Gemini hook command: %v", err) + return -1, stdout.String(), stderr.String() +} + +func legacyGeminiCommand(scriptPath string) string { + return "node " + scriptPath +} + +func assertNoGeminiRuntimeWrites(t *testing.T, fixture *geminiFixture) { + 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 assertNoGeminiTransactionArtifacts(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 Gemini fixture: %v", err) + } +} + +func geminiSymlinkOrSkip(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } +} diff --git a/cmd/elydora/plugins/gemini_transaction_test.go b/cmd/elydora/plugins/gemini_transaction_test.go new file mode 100644 index 0000000..362f654 --- /dev/null +++ b/cmd/elydora/plugins/gemini_transaction_test.go @@ -0,0 +1,222 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func snapshotGeminiFiles(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 requireGeminiSnapshot(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 prepareGeminiTestChanges( + t *testing.T, + fixture *geminiFixture, + document *geminiDocument, +) ([]*fileChange, *geminiRuntimePaths) { + t.Helper() + paths, nodePath, err := preflightGeminiInstallation(fixture.config, document) + if err != nil { + t.Fatalf("preflight Gemini installation: %v", err) + } + guard, err := buildGeminiGroup( + nodePath, + paths.guardPath, + geminiGuardHookName, + ) + if err != nil { + t.Fatalf("build Gemini guard group: %v", err) + } + audit, err := buildGeminiGroup( + nodePath, + paths.auditPath, + geminiAuditHookName, + ) + if err != nil { + t.Fatalf("build Gemini audit group: %v", err) + } + rendered, err := renderGeminiDocument( + document, + "", + map[string]map[string]any{ + "BeforeTool": guard, + "AfterTool": audit, + }, + ) + if err != nil { + t.Fatalf("render Gemini document: %v", err) + } + changes, err := prepareGeminiInstallationChanges( + fixture.config, + paths, + rendered, + ) + if err != nil { + t.Fatalf("prepare Gemini installation: %v", err) + } + return changes, paths +} + +func TestGeminiInstallRollsBackAllFiveFilesAfterSettingsFailure(t *testing.T) { + fixture := prepareGeminiFixture(t, geminiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + settings := readGeminiTestObject(t, fixture.settingsPath) + settings["owner"] = "user" + delete(requireObject(t, settings["hooks"]), "AfterTool") + writeGeminiTestObject(t, fixture.settingsPath, settings) + staleRuntime := `{"org_id":"old","agent_id":"agent-1","kid":"old","base_url":"https://old.test","agent_name":"gemini"}` + 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.settingsPath, + } + before := snapshotGeminiFiles(t, paths...) + fixture.config.OrgID = "org-updated" + fixture.config.Token = "token-updated" + fixture.plugin.rename = func(source, destination string) error { + if sameGeminiPath(destination, fixture.settingsPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Gemini settings failure") + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected Gemini settings failure") { + t.Fatalf("install error = %v", err) + } + requireGeminiSnapshot(t, before) + assertNoGeminiTransactionArtifacts(t, fixture.homeDir) +} + +func TestPreparedGeminiInstallRejectsConcurrentSettingsChange(t *testing.T) { + source := `{"owner":"original"}` + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: &source}, + ) + document, err := readGeminiDocument() + if err != nil { + t.Fatalf("read Gemini document: %v", err) + } + changes, paths := prepareGeminiTestChanges(t, fixture, document) + concurrent := []byte(`{"owner":"concurrent"}`) + if err := os.WriteFile(fixture.settingsPath, concurrent, 0600); err != nil { + t.Fatalf("write concurrent settings: %v", err) + } + err = writeGeminiChanges( + changes, + "Install Gemini CLI hooks", + nil, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.filePath), + ) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("prepared install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.settingsPath) + if readErr != nil || string(actual) != string(concurrent) { + t.Fatalf("concurrent settings changed: %q, %v", actual, readErr) + } + assertNoGeminiRuntimeWrites(t, fixture) + assertNoGeminiTransactionArtifacts(t, fixture.homeDir) +} + +func TestGeminiInstallDetectsConcurrentSettingsIdentityReplacement(t *testing.T) { + source := []byte(`{"owner":"original"}`) + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: geminiString(string(source))}, + ) + mutated := false + fixture.plugin.rename = func(stagedPath, destination string) error { + if !mutated && strings.HasSuffix(stagedPath, ".tmp") { + mutated = true + external := fixture.settingsPath + ".external" + if err := os.Rename(fixture.settingsPath, external); err != nil { + return err + } + if err := os.WriteFile(fixture.settingsPath, 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.settingsPath) + if readErr != nil || string(actual) != string(source) { + t.Fatalf("replacement settings changed: %q, %v", actual, readErr) + } + assertNoGeminiRuntimeWrites(t, fixture) + assertNoGeminiTransactionArtifacts(t, fixture.homeDir) +} + +func TestGeminiUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { + source := `{"owner":"user"}` + fixture := prepareGeminiFixture( + t, + geminiFixtureOptions{existingRaw: &source}, + ) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Gemini hooks: %v", err) + } + before, err := os.ReadFile(fixture.settingsPath) + if err != nil { + t.Fatalf("read installed Gemini settings: %v", err) + } + fixture.plugin.rename = func(source, destination string) error { + if sameGeminiPath(destination, fixture.settingsPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Gemini uninstall failure") + } + return os.Rename(source, destination) + } + err = fixture.plugin.Uninstall(geminiTestAgentID) + if err == nil || !strings.Contains(err.Error(), "injected Gemini uninstall failure") { + t.Fatalf("uninstall error = %v", err) + } + after, readErr := os.ReadFile(fixture.settingsPath) + if readErr != nil || string(after) != string(before) { + t.Fatalf("Gemini settings changed: %q, %v", after, readErr) + } + assertNoGeminiTransactionArtifacts(t, fixture.homeDir) +}