From 48a830ff4dd14ba8a4a77aa7deca88b5d050e576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 07:27:49 -0400 Subject: [PATCH] fix(go): mirror official Claude hooks --- sdks/go/README.md | 4 +- sdks/go/cmd/elydora/plugins/claudecode.go | 335 ++++--------- .../elydora/plugins/claudecode_cli_test.go | 117 +++++ .../elydora/plugins/claudecode_contract.go | 453 ++++++++++++++++++ .../plugins/claudecode_installation.go | 185 +++++++ sdks/go/cmd/elydora/plugins/claudecode_io.go | 108 +++++ .../plugins/claudecode_official_test.go | 51 ++ .../elydora/plugins/claudecode_ownership.go | 326 +++++++++++++ .../plugins/claudecode_runtime_test.go | 293 +++++++++++ .../plugins/claudecode_security_test.go | 221 +++++++++ .../cmd/elydora/plugins/claudecode_status.go | 231 +++++++++ .../go/cmd/elydora/plugins/claudecode_test.go | 332 +++++++++++++ .../plugins/claudecode_test_helpers_test.go | 315 ++++++++++++ .../plugins/claudecode_transaction_test.go | 194 ++++++++ .../elydora/plugins/legacy_hook_entries.go | 73 +++ sdks/go/cmd/elydora/plugins/registry.go | 2 +- 16 files changed, 3004 insertions(+), 236 deletions(-) create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_cli_test.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_contract.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_installation.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_io.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_official_test.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_ownership.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_runtime_test.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_security_test.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_status.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_test.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_test_helpers_test.go create mode 100644 sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go create mode 100644 sdks/go/cmd/elydora/plugins/legacy_hook_entries.go diff --git a/sdks/go/README.md b/sdks/go/README.md index 27a89ac..e2156d3 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -74,7 +74,9 @@ elydora install \ --kid agent-456-key-v1 ``` -Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Installation commits `config.json`, `private.key`, and `hook.js` as one rollback-capable runtime transaction, and the generated scripts validate those protected files at execution time. +Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Provider settings, `guard.js`, `config.json`, `private.key`, and `hook.js` commit as one rollback-capable transaction, and the generated scripts validate protected files at execution time. + +Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` exec-form hooks to `$CLAUDE_CONFIG_DIR/settings.json` (`~/.claude/settings.json` by default). Elydora preserves unrelated user settings, keeps project, local, managed, plugin, skill, and agent sources unchanged, forwards native snake_case payloads, and uses exit code `2` for frozen or revoked agents. Installation validates the complete official hook schema and commits settings with all four runtime artifacts in one rollback-capable transaction. Run `/hooks` and `claude doctor` after installation to inspect the effective hook sources. Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes. diff --git a/sdks/go/cmd/elydora/plugins/claudecode.go b/sdks/go/cmd/elydora/plugins/claudecode.go index 24ce651..cbbe5a2 100644 --- a/sdks/go/cmd/elydora/plugins/claudecode.go +++ b/sdks/go/cmd/elydora/plugins/claudecode.go @@ -2,280 +2,147 @@ package plugins import ( "fmt" - "os" "path/filepath" - "strings" ) -// ClaudeCodePlugin manages the Elydora audit hook for Claude Code. -// It merges a PostToolUse hook into ~/.claude/settings.json. -type ClaudeCodePlugin struct{} +// ClaudeCodePlugin manages Claude Code global user hooks. +type ClaudeCodePlugin struct { + rename renameFunc +} + +// ManagesGuardRuntime reports that Claude Code commits its provider guard with +// the audit runtime and user settings document. +func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool { + return true +} + +func requireClaudeHooksEnabled(document *claudeDocument) error { + if document.hooksDisabled { + return fmt.Errorf( + "Claude Code hooks are disabled by disableAllHooks: %s", + document.filePath, + ) + } + return nil +} + +// PreflightInstall validates settings and runtime identity before any write. +func (p *ClaudeCodePlugin) PreflightInstall(config InstallConfig) error { + document, err := readClaudeDocument() + if err != nil { + return err + } + if err := requireClaudeHooksEnabled(document); err != nil { + return err + } + _, _, err = preflightClaudeInstallation(config, document) + return err +} func (p *ClaudeCodePlugin) Install(config InstallConfig) error { - scriptPath, err := hookScriptPath(config.AgentID) + document, err := readClaudeDocument() if err != nil { return err } - if config.HookScript != "" { - scriptPath = config.HookScript + if err := requireClaudeHooksEnabled(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("~/.claude") + paths, nodePath, err := preflightClaudeInstallation(config, document) if err != nil { return err } - configPath := filepath.Join(configDir, "settings.json") - - settings, err := readJSONFile(configPath) + hooks, err := removeManagedClaudeHooks(document.hooks, "") if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - hooks = make(map[string]interface{}) + for _, item := range []struct{ event, script, status string }{ + {"PreToolUse", paths.guardPath, claudeGuardStatusMessage}, + {"PostToolUse", paths.auditPath, claudeAuditStatusMessage}, + {"PostToolUseFailure", paths.auditPath, claudeAuditStatusMessage}, + } { + hooks[item.event] = append( + hooks[item.event], + buildClaudeGroup(nodePath, item.script, item.status), + ) } - - // --- PreToolUse (guard — freeze enforcement) --- - preToolUse, _ := hooks["PreToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) + rendered, err := renderClaudeDocument(document, hooks) + if err != nil { + return fmt.Errorf("render Claude Code user settings: %w", err) } - guardEntry := map[string]interface{}{ - "hooks": []interface{}{ - map[string]interface{}{ - "type": "command", - "command": "node " + guardPath, - }, - }, - } - preFiltered = append(preFiltered, guardEntry) - hooks["PreToolUse"] = preFiltered - - // --- PostToolUse (audit logging) --- - postToolUse, _ := hooks["PostToolUse"].([]interface{}) - var postFiltered []interface{} - for _, entry := range postToolUse { - 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["PostToolUse"] = postFiltered - - settings["hooks"] = hooks - - if err := writeJSONFile(configPath, settings); err != nil { + changes, err := prepareClaudeInstallationChanges(config, paths, rendered) + if err != nil { return err } - fmt.Printf("Installed Elydora hook for Claude Code at %s\n", configPath) + if err := writeClaudeChanges( + changes, + "Install Claude Code hooks", + p.rename, + paths.runtimeRoot, + paths.agentDirectory, + filepath.Dir(document.filePath), + ); err != nil { + return err + } + fmt.Printf("Claude Code hooks installed at %s.\n", document.filePath) + fmt.Println("Claude Code verification: run /hooks and claude doctor.") return nil } func (p *ClaudeCodePlugin) Uninstall(agentID string) error { - configDir, err := expandHome("~/.claude") + document, err := readClaudeDocument() if err != nil { return err } - configPath := filepath.Join(configDir, "settings.json") - - settings, err := readJSONFile(configPath) + hooks, err := removeManagedClaudeHooks(document.hooks, agentID) if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - fmt.Println("No Claude Code hooks found.") - return nil + rendered, err := renderClaudeDocument(document, hooks) + if err != nil { + return fmt.Errorf("render Claude Code user settings: %w", err) } - - // Remove PreToolUse Elydora entries - preToolUse, _ := hooks["PreToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) - } - if len(preFiltered) == 0 { - delete(hooks, "PreToolUse") - } else { - hooks["PreToolUse"] = preFiltered - } - - // Remove PostToolUse Elydora entries - postToolUse, _ := hooks["PostToolUse"].([]interface{}) - var postFiltered []interface{} - for _, entry := range postToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - postFiltered = append(postFiltered, entry) - } - if len(postFiltered) == 0 { - delete(hooks, "PostToolUse") - } else { - hooks["PostToolUse"] = postFiltered - } - - if len(hooks) == 0 { - delete(settings, "hooks") - } else { - settings["hooks"] = hooks - } - - if err := writeJSONFile(configPath, settings); err != nil { + change, err := prepareRenderedClaudeChange(rendered) + if 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 Claude Code.") - return nil + return writeClaudeChanges( + []*fileChange{change}, + "Uninstall Claude Code hooks", + p.rename, + "", + "", + filepath.Dir(document.filePath), + ) } func (p *ClaudeCodePlugin) Status() (PluginStatus, error) { - configDir, err := expandHome("~/.claude") - if err != nil { - return PluginStatus{}, err - } - configPath := filepath.Join(configDir, "settings.json") - + configPath, pathErr := claudeSettingsPath() + entry := SupportedAgents[claudeAgentKey] status := PluginStatus{ - AgentName: "claudecode", - DisplayName: "Claude Code", - ConfigPath: configPath, + AgentName: claudeAgentKey, DisplayName: entry.Name, ConfigPath: configPath, } - - // Check if hook is configured in settings and extract hook script path - settings, err := readJSONFile(configPath) + if pathErr != nil { + return status, pathErr + } + document, err := readClaudeDocument() if err != nil { + return status, err + } + if document.hooksDisabled { return status, nil } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks != nil { - preConfigured := hasElydoraEntry(hooks["PreToolUse"]) - postConfigured := hasElydoraEntry(hooks["PostToolUse"]) - status.HookConfigured = preConfigured && postConfigured - - // Extract hook script path from the configured command - scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]) - if scriptPath != "" { - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - } + contracts, err := claudeRuntimeContracts(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 = claudeRuntimeFilesExist(contracts) + if err != nil { + return status, err + } + status.Installed = status.HookScriptExists return status, nil } - -// extractElydoraScriptPath extracts the script path from a hook array's Elydora command entry. -func extractElydoraScriptPath(hookArray interface{}) string { - arr, _ := hookArray.([]interface{}) - for _, entry := range arr { - if m, ok := entry.(map[string]interface{}); ok { - // New format: { "hooks": [{ "type": "command", "command": "node /path/to/hook.js" }] } - if innerHooks, ok := m["hooks"].([]interface{}); ok { - for _, h := range innerHooks { - if hm, ok := h.(map[string]interface{}); ok { - if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") { - return extractPathFromNodeCommand(cmd) - } - } - } - } - // Old format: { "command": "node /path/to/hook.js" } - if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") { - return extractPathFromNodeCommand(cmd) - } - } - } - return "" -} - -// extractPathFromNodeCommand extracts the file path from a "node /path/to/script.js" command. -func extractPathFromNodeCommand(cmd string) string { - cmd = strings.TrimSpace(cmd) - if strings.HasPrefix(cmd, "node ") { - return strings.TrimSpace(cmd[5:]) - } - return "" -} - -// hasElydoraEntry checks if a hook array (interface{}) contains an Elydora entry. -func hasElydoraEntry(hookArray interface{}) bool { - arr, _ := hookArray.([]interface{}) - for _, entry := range arr { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - return true - } - } - } - return false -} - -// isElydoraHookEntry checks if a hook entry (old or new format) is an Elydora hook. -func isElydoraHookEntry(m map[string]interface{}) bool { - // New format: { "matcher": {}, "hooks": [{ "type": "command", "command": "..." }] } - if innerHooks, ok := m["hooks"].([]interface{}); ok { - for _, h := range innerHooks { - if hm, ok := h.(map[string]interface{}); ok { - if cmd, _ := hm["command"].(string); strings.Contains(cmd, "elydora") { - return true - } - } - } - } - // Old format: { "command": "..." } - if cmd, _ := m["command"].(string); strings.Contains(cmd, "elydora") { - return true - } - return false -} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_cli_test.go b/sdks/go/cmd/elydora/plugins/claudecode_cli_test.go new file mode 100644 index 0000000..e22cd28 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_cli_test.go @@ -0,0 +1,117 @@ +package plugins + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func claudeRunCLI( + t *testing.T, + binary string, + fixture *claudeFixture, + 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, + "CLAUDE_CONFIG_DIR="+fixture.configDir, + ) + 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 TestClaudeCLIInstallStatusAndUninstallEndToEnd(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 := prepareClaudeFixture( + t, + claudeFixtureOptions{ + configEnvPresent: true, + configEnvOverride: filepath.Join(t.TempDir(), "claude config"), + }, + ) + privateKeyFile := filepath.Join(t.TempDir(), "private.key") + tokenFile := filepath.Join(t.TempDir(), "api.token") + if err := os.WriteFile(privateKeyFile, []byte(claudePrivateKey+"\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 := claudeRunCLI( + t, + binary, + fixture, + "install", + "--agent", claudeAgentKey, + "--org-id", "org-1", + "--agent-id", claudeTestAgentID, + "--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, "claude doctor") { + t.Fatalf("CLI install = %d, %q, %q", exit, stdout, stderr) + } + requireStrictClaudeTriple(t, fixture) + stdout, stderr, exit = claudeRunCLI( + t, + binary, + fixture, + "status", + "--agent", claudeAgentKey, + ) + if exit != 0 || stderr != "" || !strings.Contains(stdout, "installed") { + t.Fatalf("CLI status = %d, %q, %q", exit, stdout, stderr) + } + stdout, stderr, exit = claudeRunCLI( + t, + binary, + fixture, + "uninstall", + "--agent", claudeAgentKey, + "--agent-id", claudeTestAgentID, + ) + 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.configPath); !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/sdks/go/cmd/elydora/plugins/claudecode_contract.go b/sdks/go/cmd/elydora/plugins/claudecode_contract.go new file mode 100644 index 0000000..bbdf2ce --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_contract.go @@ -0,0 +1,453 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "net/url" + "reflect" + "sort" +) + +const ( + claudeAgentKey = "claudecode" + claudeConfigFile = "settings.json" + claudeGuardScript = "guard.js" + claudeAuditScript = "hook.js" + claudeHookTimeout = float64(10) + claudeGuardStatusMessage = "Checking Elydora agent state" + claudeAuditStatusMessage = "Recording Elydora tool use" +) + +var claudeHookEvents = stringSet( + "SessionStart", "Setup", "InstructionsLoaded", "UserPromptSubmit", + "UserPromptExpansion", "MessageDisplay", "PreToolUse", "PermissionRequest", + "PostToolUse", "PostToolUseFailure", "PostToolBatch", "PermissionDenied", + "Notification", "SubagentStart", "SubagentStop", "TaskCreated", + "TaskCompleted", "Stop", "StopFailure", "TeammateIdle", "ConfigChange", + "CwdChanged", "FileChanged", "WorktreeCreate", "WorktreeRemove", + "PreCompact", "PostCompact", "SessionEnd", "Elicitation", + "ElicitationResult", +) + +var claudeHandlerKeys = map[string]map[string]struct{}{ + "command": stringSet( + "if", "once", "statusMessage", "timeout", "args", "async", + "asyncRewake", "command", "shell", "type", + ), + "prompt": stringSet( + "if", "once", "statusMessage", "timeout", "continueOnBlock", "model", + "prompt", "type", + ), + "agent": stringSet( + "if", "once", "statusMessage", "timeout", "model", "prompt", "type", + ), + "http": stringSet( + "if", "once", "statusMessage", "timeout", "allowedEnvVars", "headers", + "type", "url", + ), + "mcp_tool": stringSet( + "if", "once", "statusMessage", "timeout", "input", "server", "tool", + "type", + ), +} + +type claudeGroup struct { + object map[string]any + handlers []map[string]any +} + +type claudeHooks map[string][]claudeGroup + +type claudeDocument struct { + exists bool + filePath string + root map[string]any + hooks claudeHooks + hooksDisabled bool + raw []byte +} + +type claudeRenderedDocument struct { + document *claudeDocument + changed bool + next []byte + remove bool +} + +type claudeRuntimeContract struct { + agentID string + guardPath string + auditPath string +} + +func cloneClaudeObject(value map[string]any) map[string]any { + clone := make(map[string]any, len(value)) + for key, item := range value { + clone[key] = item + } + return clone +} + +func cloneClaudeHooks(source claudeHooks) claudeHooks { + clone := make(claudeHooks, len(source)) + for event, groups := range source { + clone[event] = append([]claudeGroup(nil), groups...) + } + return clone +} + +func validateClaudeKnownKeys( + value map[string]any, + allowed map[string]struct{}, + label string, +) error { + fields := make([]string, 0, len(value)) + for field := range value { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + if _, ok := allowed[field]; !ok { + return fmt.Errorf(`%s contains unsupported field %q`, label, field) + } + } + return nil +} + +func requireClaudeString(value map[string]any, field, label string) (string, error) { + text, ok := value[field].(string) + if !ok || text == "" { + return "", fmt.Errorf(`%s field %q must be a non-empty string`, label, field) + } + return text, nil +} + +func validateClaudeOptionalString(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 validateClaudeOptionalBoolean(value map[string]any, field, label string) error { + if item, exists := value[field]; exists { + if _, ok := item.(bool); !ok { + return fmt.Errorf(`%s field %q must be a boolean`, label, field) + } + } + return nil +} + +func validateClaudeCommonFields(value map[string]any, label string) error { + for _, field := range []string{"if", "statusMessage"} { + if err := validateClaudeOptionalString(value, field, label); err != nil { + return err + } + } + if err := validateClaudeOptionalBoolean(value, "once", label); err != nil { + return err + } + if item, exists := value["timeout"]; exists { + timeout, ok := item.(float64) + if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) || timeout <= 0 { + return fmt.Errorf("%s timeout must be a positive finite number", label) + } + } + return nil +} + +func validateClaudeStringArray(value any, field, label string, nonEmpty bool) error { + items, ok := value.([]any) + if !ok { + return fmt.Errorf(`%s field %q must be an array of strings`, label, field) + } + for _, item := range items { + text, ok := item.(string) + if !ok { + return fmt.Errorf(`%s field %q must be an array of strings`, label, field) + } + if nonEmpty && text == "" { + return fmt.Errorf( + `%s field %q must contain non-empty strings`, + label, + field, + ) + } + } + return nil +} + +func validateClaudeHTTPHandler(handler map[string]any, label string) error { + rawURL, err := requireClaudeString(handler, "url", label) + if err != nil { + return err + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf(`%s field "url" must be a valid URL`, label) + } + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" { + return fmt.Errorf(`%s field "url" must use HTTP or HTTPS`, label) + } + if headers, exists := handler["headers"]; exists { + object, ok := headers.(map[string]any) + if !ok { + return fmt.Errorf(`%s field "headers" must map names to strings`, label) + } + for _, item := range object { + if _, ok := item.(string); !ok { + return fmt.Errorf(`%s field "headers" must map names to strings`, label) + } + } + } + if allowed, exists := handler["allowedEnvVars"]; exists { + return validateClaudeStringArray(allowed, "allowedEnvVars", label, true) + } + return nil +} + +func validateClaudeHandler( + value any, + event string, + groupIndex int, + handlerIndex int, +) (map[string]any, error) { + label := fmt.Sprintf( + "Claude Code settings handler hooks.%s[%d].hooks[%d]", + event, + groupIndex, + handlerIndex, + ) + handler, ok := value.(map[string]any) + if !ok || handler == nil { + return nil, fmt.Errorf("%s must be an object", label) + } + handlerType, ok := handler["type"].(string) + allowed, supported := claudeHandlerKeys[handlerType] + if !ok || !supported { + return nil, fmt.Errorf(`%s has unsupported type %q`, label, fmt.Sprint(handler["type"])) + } + if err := validateClaudeKnownKeys(handler, allowed, label); err != nil { + return nil, err + } + if err := validateClaudeCommonFields(handler, label); err != nil { + return nil, err + } + switch handlerType { + case "command": + if _, err := requireClaudeString(handler, "command", label); err != nil { + return nil, err + } + if args, exists := handler["args"]; exists { + if err := validateClaudeStringArray(args, "args", label, false); err != nil { + return nil, err + } + } + for _, field := range []string{"async", "asyncRewake"} { + if err := validateClaudeOptionalBoolean(handler, field, label); err != nil { + return nil, err + } + } + if shell, exists := handler["shell"]; exists && + shell != "bash" && shell != "powershell" { + return nil, fmt.Errorf( + `%s field "shell" must be "bash" or "powershell"`, + label, + ) + } + case "prompt", "agent": + if _, err := requireClaudeString(handler, "prompt", label); err != nil { + return nil, err + } + if err := validateClaudeOptionalString(handler, "model", label); err != nil { + return nil, err + } + if handlerType == "prompt" { + if err := validateClaudeOptionalBoolean( + handler, + "continueOnBlock", + label, + ); err != nil { + return nil, err + } + } + case "http": + if err := validateClaudeHTTPHandler(handler, label); err != nil { + return nil, err + } + case "mcp_tool": + for _, field := range []string{"server", "tool"} { + if _, err := requireClaudeString(handler, field, label); err != nil { + return nil, err + } + } + if input, exists := handler["input"]; exists { + if _, ok := input.(map[string]any); !ok { + return nil, fmt.Errorf(`%s field "input" must be an object`, label) + } + } + } + return cloneClaudeObject(handler), nil +} + +func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) { + label := fmt.Sprintf("Claude Code settings matcher group hooks.%s[%d]", event, groupIndex) + object, ok := value.(map[string]any) + if !ok || object == nil { + return claudeGroup{}, fmt.Errorf("%s must be an object", label) + } + if err := validateClaudeKnownKeys( + object, + stringSet("hooks", "matcher"), + label, + ); err != nil { + return claudeGroup{}, err + } + if matcher, exists := object["matcher"]; exists { + if _, ok := matcher.(string); !ok { + return claudeGroup{}, fmt.Errorf("%s matcher must be a string", label) + } + } + values, ok := object["hooks"].([]any) + if !ok { + return claudeGroup{}, fmt.Errorf("%s must contain a hooks array", label) + } + handlers := make([]map[string]any, 0, len(values)) + for handlerIndex, value := range values { + handler, err := validateClaudeHandler(value, event, groupIndex, handlerIndex) + if err != nil { + return claudeGroup{}, err + } + handlers = append(handlers, handler) + } + return claudeGroup{object: cloneClaudeObject(object), handlers: handlers}, nil +} + +func readClaudeHooks(root map[string]any) (claudeHooks, error) { + value, exists := root["hooks"] + if !exists { + return claudeHooks{}, nil + } + object, ok := value.(map[string]any) + if !ok || object == nil { + return nil, fmt.Errorf(`Claude Code settings field "hooks" must be an object`) + } + hooks := make(claudeHooks, len(object)) + for event, groupsValue := range object { + if _, supported := claudeHookEvents[event]; !supported { + return nil, fmt.Errorf( + `Claude Code settings contains unsupported hook event %q`, + event, + ) + } + values, ok := groupsValue.([]any) + if !ok { + return nil, fmt.Errorf( + `Claude Code settings field "hooks.%s" must be an array`, + event, + ) + } + groups := make([]claudeGroup, 0, len(values)) + for index, value := range values { + group, err := validateClaudeGroup(value, event, index) + if err != nil { + return nil, err + } + groups = append(groups, group) + } + hooks[event] = groups + } + return hooks, nil +} + +func parseClaudeDocument(filePath string, raw []byte) (*claudeDocument, error) { + label := fmt.Sprintf("Claude Code user settings at %s", filePath) + root, err := decodeStrictJSONObject(raw, label) + if err != nil { + return nil, err + } + disabled := false + if value, exists := root["disableAllHooks"]; exists { + var ok bool + disabled, ok = value.(bool) + if !ok { + return nil, fmt.Errorf( + `Claude Code settings field "disableAllHooks" must be a boolean`, + ) + } + } + hooks, err := readClaudeHooks(root) + if err != nil { + return nil, err + } + return &claudeDocument{ + exists: true, filePath: filePath, root: root, hooks: hooks, + hooksDisabled: disabled, raw: append([]byte(nil), raw...), + }, nil +} + +func createClaudeDocument(filePath string) *claudeDocument { + return &claudeDocument{ + filePath: filePath, + root: map[string]any{}, + hooks: claudeHooks{}, + } +} + +func renderClaudeHooks(hooks claudeHooks) map[string]any { + result := make(map[string]any, len(hooks)) + for event, groups := range hooks { + values := make([]any, 0, len(groups)) + for _, group := range groups { + object := cloneClaudeObject(group.object) + handlers := make([]any, 0, len(group.handlers)) + for _, handler := range group.handlers { + handlers = append(handlers, handler) + } + object["hooks"] = handlers + values = append(values, object) + } + result[event] = values + } + return result +} + +func renderClaudeDocument( + document *claudeDocument, + hooks claudeHooks, +) (*claudeRenderedDocument, error) { + if !document.exists && len(hooks) == 0 { + return &claudeRenderedDocument{document: document}, nil + } + if reflect.DeepEqual(hooks, document.hooks) { + return &claudeRenderedDocument{document: document}, nil + } + if len(hooks) == 0 && entirelyManagedClaudeDocument(document) { + return &claudeRenderedDocument{ + document: document, changed: true, remove: true, + }, nil + } + root := cloneClaudeObject(document.root) + if len(hooks) == 0 { + delete(root, "hooks") + } else { + root["hooks"] = renderClaudeHooks(hooks) + } + next, err := json.MarshalIndent(root, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Claude Code user settings: %w", err) + } + next = append(next, '\n') + if _, err := parseClaudeDocument(document.filePath, next); err != nil { + return nil, fmt.Errorf("validate rendered Claude Code user settings: %w", err) + } + return &claudeRenderedDocument{ + document: document, + changed: !document.exists || !bytes.Equal(next, document.raw), + next: next, + }, nil +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_installation.go b/sdks/go/cmd/elydora/plugins/claudecode_installation.go new file mode 100644 index 0000000..27f5a5e --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_installation.go @@ -0,0 +1,185 @@ +package plugins + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type claudeRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string +} + +func validateClaudeInstallConfig(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 != claudeAgentKey { + return fmt.Errorf( + "Claude Code installation requires agent name %s", + claudeAgentKey, + ) + } + 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 claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) { + if err := validateClaudeInstallConfig(config); err != nil { + return nil, err + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + paths := &claudeRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, claudeGuardScript), + auditPath: filepath.Join(agentDirectory, claudeAuditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameClaudePath(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) || + !sameClaudePath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "audit runtime must use the managed Elydora agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightClaudeInstallation( + config InstallConfig, + document *claudeDocument, +) (*claudeRuntimePaths, string, error) { + if document == nil || document.filePath == "" { + return nil, "", fmt.Errorf( + "Claude Code installation requires a user settings document", + ) + } + paths, err := claudeAgentPaths(config) + if err != nil { + return nil, "", err + } + if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + return nil, "", err + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + if err := requireClaudeAbsoluteNode(nodePath); err != nil { + return nil, "", err + } + return paths, nodePath, nil +} + +func buildClaudeRuntimeConfig(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": claudeAgentKey, + } + 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 prepareClaudeInstallationChanges( + config InstallConfig, + paths *claudeRuntimePaths, + rendered *claudeRenderedDocument, +) ([]*fileChange, error) { + runtimeConfig, err := buildClaudeRuntimeConfig(config) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + { + paths.guardPath, "Elydora guard runtime", + []byte(generateGuardScript(claudeAgentKey, config.AgentID, "", 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( + claudeAgentKey, + config.AgentID, + "", + false, + true, + )), + 0700, + }, + } + changes := make([]*fileChange, 0, len(items)+1) + for _, item := range items { + change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + documentChange, err := prepareRenderedClaudeChange(rendered) + if err != nil { + return nil, err + } + return append(changes, documentChange), nil +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_io.go b/sdks/go/cmd/elydora/plugins/claudecode_io.go new file mode 100644 index 0000000..996ec83 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_io.go @@ -0,0 +1,108 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" +) + +func claudeConfigDirectory() (string, error) { + configured, exists := os.LookupEnv("CLAUDE_CONFIG_DIR") + if !exists { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".claude"), nil + } + resolved, err := filepath.Abs(configured) + if err != nil { + return "", fmt.Errorf("resolve CLAUDE_CONFIG_DIR at %s: %w", configured, err) + } + return resolved, nil +} + +func claudeSettingsPath() (string, error) { + directory, err := claudeConfigDirectory() + if err != nil { + return "", err + } + return filepath.Join(directory, claudeConfigFile), nil +} + +func readClaudeDocument() (*claudeDocument, error) { + filePath, err := claudeSettingsPath() + if err != nil { + return nil, err + } + if _, err := managedPhysicalDirectoryExists( + filepath.Dir(filePath), + "Claude Code configuration directory", + ); err != nil { + return nil, err + } + snapshot, err := readManagedFile( + filePath, + "Claude Code user settings", + maxManagedSourceBytes, + ) + if err != nil { + return nil, err + } + if snapshot == nil { + return createClaudeDocument(filePath), nil + } + return parseClaudeDocument(filePath, snapshot.contents) +} + +func prepareRenderedClaudeChange( + rendered *claudeRenderedDocument, +) (*fileChange, error) { + if rendered == nil || !rendered.changed { + return nil, nil + } + return prepareSourceChange( + rendered.document.filePath, + "Claude Code user settings", + rendered.document.raw, + rendered.document.exists, + rendered.next, + 0600, + rendered.remove, + ) +} + +func writeClaudeChanges( + 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, + "Claude Code configuration directory", + ); err != nil { + return err + } + return writeChanges(changes, label, rename) +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_official_test.go b/sdks/go/cmd/elydora/plugins/claudecode_official_test.go new file mode 100644 index 0000000..451a3ec --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_official_test.go @@ -0,0 +1,51 @@ +package plugins + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestClaudeOfficialBinaryAcceptsInstalledSettings(t *testing.T) { + binary := os.Getenv("ELYDORA_CLAUDE_BINARY") + if binary == "" { + t.Skip("ELYDORA_CLAUDE_BINARY is unset") + } + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + environment := append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "CLAUDE_CONFIG_DIR="+fixture.configDir, + "DISABLE_AUTOUPDATER=1", + "DISABLE_TELEMETRY=1", + ) + version := exec.Command(binary, "--version") + version.Dir = fixture.projectDir + version.Env = environment + versionOutput, err := version.CombinedOutput() + if err != nil || !strings.Contains(string(versionOutput), "Claude Code") { + t.Fatalf("official Claude version: %v\n%s", err, versionOutput) + } + doctor := exec.Command(binary, "doctor") + doctor.Dir = fixture.projectDir + doctor.Env = environment + doctorOutput, err := doctor.CombinedOutput() + if err != nil { + t.Fatalf("official Claude doctor: %v\n%s", err, doctorOutput) + } + output := strings.ToLower(string(doctorOutput)) + for _, problem := range []string{ + "invalid settings", + "settings validation", + "failed to parse", + } { + if strings.Contains(output, problem) { + t.Fatalf("official Claude doctor reported %q:\n%s", problem, doctorOutput) + } + } +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_ownership.go b/sdks/go/cmd/elydora/plugins/claudecode_ownership.go new file mode 100644 index 0000000..f3c6aa4 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_ownership.go @@ -0,0 +1,326 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "runtime" + "sort" + "strings" +) + +type claudeRuntimeReference struct { + agentID string + scriptPath string +} + +func sameClaudePath(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 sameClaudeAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func isClaudeNodeExecutable(path string) bool { + name := filepath.Base(path) + return name == "node" || strings.EqualFold(name, "node.exe") +} + +func sameClaudeFileName(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func claudeRuntimeReferenceForScript( + scriptPath string, + scriptName string, +) (*claudeRuntimeReference, error) { + if !filepath.IsAbs(scriptPath) || + !sameClaudeFileName(filepath.Base(scriptPath), scriptName) { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil +} + +func claudeLegacyReference( + command string, + scriptName string, +) (*claudeRuntimeReference, error) { + if !strings.HasPrefix(command, "node ") || strings.ContainsAny(command, "\r\n") { + return nil, nil + } + scriptPath := strings.TrimPrefix(command, "node ") + if scriptPath == "" || strings.TrimSpace(scriptPath) != scriptPath { + return nil, nil + } + return claudeRuntimeReferenceForScript(scriptPath, scriptName) +} + +func managedClaudeReference( + handler map[string]any, + scriptName string, + statusMessage string, + includeLegacy bool, +) (*claudeRuntimeReference, error) { + if len(handler) == 5 && handler["type"] == "command" && + handler["timeout"] == claudeHookTimeout && + handler["statusMessage"] == statusMessage { + nodePath, nodeOK := handler["command"].(string) + args, argsOK := handler["args"].([]any) + if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) && + argsOK && len(args) == 1 { + if scriptPath, ok := args[0].(string); ok { + return claudeRuntimeReferenceForScript(scriptPath, scriptName) + } + } + } + if includeLegacy && len(handler) == 2 && handler["type"] == "command" { + if command, ok := handler["command"].(string); ok { + return claudeLegacyReference(command, scriptName) + } + } + return nil, nil +} + +func buildClaudeGroup(nodePath, scriptPath, statusMessage string) claudeGroup { + return claudeGroup{ + object: map[string]any{}, + handlers: []map[string]any{{ + "type": "command", "command": nodePath, "args": []any{scriptPath}, + "timeout": claudeHookTimeout, "statusMessage": statusMessage, + }}, + } +} + +func exactManagedClaudeGroup(group claudeGroup) bool { + _, hasHooks := group.object["hooks"] + return len(group.object) == 1 && hasHooks +} + +func removeManagedClaudeGroups( + groups []claudeGroup, + scriptName string, + statusMessage string, + agentID string, +) ([]claudeGroup, bool, error) { + result := make([]claudeGroup, 0, len(groups)) + removed := false + for _, group := range groups { + kept := make([]map[string]any, 0, len(group.handlers)) + for _, handler := range group.handlers { + reference, err := managedClaudeReference( + handler, + scriptName, + statusMessage, + true, + ) + if err != nil { + return nil, false, err + } + owned := reference != nil && + (agentID == "" || sameClaudeAgentID(reference.agentID, agentID)) + if owned { + removed = true + continue + } + kept = append(kept, handler) + } + if len(kept) > 0 || !exactManagedClaudeGroup(group) { + object := cloneClaudeObject(group.object) + object["hooks"] = group.object["hooks"] + result = append(result, claudeGroup{object: object, handlers: kept}) + } + } + return result, removed, nil +} + +func removeManagedClaudeHooks(hooks claudeHooks, agentID string) (claudeHooks, error) { + result := cloneClaudeHooks(hooks) + for _, contract := range []struct{ event, script, status string }{ + {"PreToolUse", claudeGuardScript, claudeGuardStatusMessage}, + {"PostToolUse", claudeAuditScript, claudeAuditStatusMessage}, + {"PostToolUseFailure", claudeAuditScript, claudeAuditStatusMessage}, + } { + groups, removed, err := removeManagedClaudeGroups( + result[contract.event], + contract.script, + contract.status, + agentID, + ) + if err != nil { + return nil, err + } + if !removed { + continue + } + if len(groups) == 0 { + delete(result, contract.event) + } else { + result[contract.event] = groups + } + } + return result, nil +} + +func managedClaudeEvent(event string) (string, string, bool) { + switch event { + case "PreToolUse": + return claudeGuardScript, claudeGuardStatusMessage, true + case "PostToolUse", "PostToolUseFailure": + return claudeAuditScript, claudeAuditStatusMessage, true + default: + return "", "", false + } +} + +func entirelyManagedClaudeDocument(document *claudeDocument) bool { + if !document.exists || len(document.root) != 1 || len(document.hooks) == 0 { + return false + } + if _, hasHooks := document.root["hooks"]; !hasHooks { + return false + } + handlerCount := 0 + for event, groups := range document.hooks { + scriptName, statusMessage, managedEvent := managedClaudeEvent(event) + if !managedEvent || len(groups) == 0 { + return false + } + for _, group := range groups { + if !exactManagedClaudeGroup(group) || len(group.handlers) == 0 { + return false + } + for _, handler := range group.handlers { + reference, err := managedClaudeReference( + handler, + scriptName, + statusMessage, + true, + ) + if err != nil || reference == nil { + return false + } + handlerCount++ + } + } + } + return handlerCount > 0 +} + +func claudeReferenceKey(agentID string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(agentID) + } + return agentID +} + +func claudeReferencesForEvent( + groups []claudeGroup, + scriptName string, + statusMessage string, +) (map[string][]claudeRuntimeReference, error) { + result := map[string][]claudeRuntimeReference{} + for _, group := range groups { + if !exactManagedClaudeGroup(group) { + continue + } + for _, handler := range group.handlers { + reference, err := managedClaudeReference( + handler, + scriptName, + statusMessage, + false, + ) + if err != nil { + return nil, err + } + if reference != nil { + key := claudeReferenceKey(reference.agentID) + result[key] = append(result[key], *reference) + } + } + } + return result, nil +} + +func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error) { + guards, err := claudeReferencesForEvent( + hooks["PreToolUse"], + claudeGuardScript, + claudeGuardStatusMessage, + ) + if err != nil { + return nil, err + } + successes, err := claudeReferencesForEvent( + hooks["PostToolUse"], + claudeAuditScript, + claudeAuditStatusMessage, + ) + if err != nil { + return nil, err + } + failures, err := claudeReferencesForEvent( + hooks["PostToolUseFailure"], + claudeAuditScript, + claudeAuditStatusMessage, + ) + 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([]claudeRuntimeContract, 0, len(keys)) + for _, key := range keys { + guard := guards[key] + success := successes[key] + failure := failures[key] + if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || + !sameClaudePath(success[0].scriptPath, failure[0].scriptPath) { + continue + } + contracts = append(contracts, claudeRuntimeContract{ + agentID: guard[0].agentID, guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, + }) + } + return contracts, nil +} + +func requireClaudeAbsoluteNode(nodePath string) error { + if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) { + return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path") + } + return nil +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_runtime_test.go b/sdks/go/cmd/elydora/plugins/claudecode_runtime_test.go new file mode 100644 index 0000000..a4df066 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_runtime_test.go @@ -0,0 +1,293 @@ +package plugins + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func installClaudeRuntimeFixture( + t *testing.T, + status string, +) (*claudeFixture, *codexTestAPI, *httptest.Server) { + t.Helper() + api := &codexTestAPI{status: status} + server := httptest.NewServer(api) + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + 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 Claude hooks: %v", err) + } + return fixture, api, server +} + +func TestClaudeGeneratedRuntimePreservesNativePayloadAuthAndChain(t *testing.T) { + fixture, api, server := installClaudeRuntimeFixture(t, "active") + defer server.Close() + settings := readClaudeTestObject(t, fixture.configPath) + guard := claudeTestManagedHandler( + t, + settings, + "PreToolUse", + claudeGuardScript, + claudeGuardStatusMessage, + ) + success := claudeTestManagedHandler( + t, + settings, + "PostToolUse", + claudeAuditScript, + claudeAuditStatusMessage, + ) + failure := claudeTestManagedHandler( + t, + settings, + "PostToolUseFailure", + claudeAuditScript, + claudeAuditStatusMessage, + ) + if exit, stdout, stderr := runClaudeHandler( + t, + guard, + fixture, + claudeOfficialPayload("PreToolUse"), + ); exit != 0 || stdout != "" || stderr != "" { + t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr) + } + successPayload := claudeOfficialPayload("PostToolUse") + failurePayload := claudeOfficialPayload("PostToolUseFailure") + for _, item := range []struct { + handler map[string]any + payload map[string]any + }{ + {success, successPayload}, + {failure, failurePayload}, + } { + if exit, stdout, stderr := runClaudeHandler( + t, + item.handler, + fixture, + item.payload, + ); exit != 0 || stdout != "" || 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"], successPayload) || + !reflect.DeepEqual(operations[1]["payload"], failurePayload) { + t.Fatalf("Claude native operations = %#v", operations) + } + if operations[0]["prev_chain_hash"] != + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" || + operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] { + t.Fatalf("Claude operation chain = %#v", operations) + } +} + +func TestClaudeGuardUsesOfficialBlockingExitCode(t *testing.T) { + for _, status := range []string{"frozen", "revoked"} { + t.Run(status, func(t *testing.T) { + fixture, _, server := installClaudeRuntimeFixture(t, status) + defer server.Close() + guard := claudeTestManagedHandler( + t, + readClaudeTestObject(t, fixture.configPath), + "PreToolUse", + claudeGuardScript, + claudeGuardStatusMessage, + ) + exit, stdout, stderr := runClaudeHandler( + t, + guard, + fixture, + claudeOfficialPayload("PreToolUse"), + ) + if exit != 2 || stdout != "" || + !strings.Contains(strings.ToLower(stderr), status) { + t.Fatalf("guard result = %d, %q, %q", exit, stdout, stderr) + } + }) + } +} + +func TestClaudeFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) { + fixture, api, server := installClaudeRuntimeFixture(t, "active") + guard := claudeTestManagedHandler( + t, + readClaudeTestObject(t, fixture.configPath), + "PreToolUse", + claudeGuardScript, + claudeGuardStatusMessage, + ) + runtimeConfig, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read runtime config: %v", err) + } + malformedExit, _, malformedError := runClaudeRawHandler( + t, + guard, + fixture, + []byte("{ malformed"), + ) + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + configExit, _, configError := runClaudeHandler( + t, + guard, + fixture, + claudeOfficialPayload("PreToolUse"), + ) + if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil { + t.Fatalf("restore runtime config: %v", err) + } + _ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json")) + api.mu.Lock() + api.status = "unknown" + api.mu.Unlock() + statusExit, _, statusError := runClaudeHandler( + t, + guard, + fixture, + claudeOfficialPayload("PreToolUse"), + ) + _ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json")) + server.Close() + apiExit, _, apiError := runClaudeHandler( + t, + guard, + fixture, + claudeOfficialPayload("PreToolUse"), + ) + for _, result := range []struct { + exit int + err string + want string + }{ + {malformedExit, malformedError, "invalid JSON"}, + {configExit, configError, "Failed to read agent config"}, + {statusExit, statusError, "invalid agent status"}, + {apiExit, apiError, "Failed to resolve agent status"}, + } { + if result.exit != 0 || !strings.Contains(result.err, result.want) { + t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want) + } + } +} + +func TestClaudeAuditRecordsMalformedInputAndAPIFailures(t *testing.T) { + fixture, api, server := installClaudeRuntimeFixture(t, "active") + defer server.Close() + handler := claudeTestManagedHandler( + t, + readClaudeTestObject(t, fixture.configPath), + "PostToolUseFailure", + claudeAuditScript, + claudeAuditStatusMessage, + ) + malformedExit, malformedOutput, malformedError := runClaudeRawHandler( + t, + handler, + fixture, + []byte("{ malformed"), + ) + api.mu.Lock() + api.postStatus = http.StatusServiceUnavailable + api.mu.Unlock() + upstreamExit, upstreamOutput, upstreamError := runClaudeHandler( + t, + handler, + fixture, + claudeOfficialPayload("PostToolUseFailure"), + ) + log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log")) + if malformedExit != 0 || malformedOutput != "" || malformedError != "" || + upstreamExit != 0 || upstreamOutput != "" || upstreamError != "" || + err != nil || !strings.Contains(string(log), "invalid JSON") || + !strings.Contains(string(log), "HTTP 503") { + t.Fatalf( + "audit failures = (%d, %q, %q), (%d, %q, %q), log=%q, err=%v", + malformedExit, + malformedOutput, + malformedError, + upstreamExit, + upstreamOutput, + upstreamError, + log, + err, + ) + } +} + +func TestClaudeStatusRequiresStrictRuntimeFiles(t *testing.T) { + for _, name := range []string{"config.json", "private.key", "guard.js", "hook.js"} { + t.Run("missing "+name, func(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude 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 TestClaudeStatusSurfacesInvalidRuntimeMetadata(t *testing.T) { + tests := []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":"claudecode","extra":true}`, "unsupported field"}, + {"identity", `{"org_id":"o","agent_id":"other","kid":"k","base_url":"https://api.test","agent_name":"claudecode"}`, "identity does not match"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude 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 TestClaudeRuntimeConfigOmitsEmptyToken(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + fixture.config.Token = "" + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + config := readClaudeTestObject(t, fixture.runtimeConfig) + if _, exists := config["token"]; exists { + t.Fatalf("empty token persisted: %#v", config) + } + raw, err := json.Marshal(config) + if err != nil || len(raw) == 0 { + t.Fatalf("runtime config encoding = %q, %v", raw, err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_security_test.go b/sdks/go/cmd/elydora/plugins/claudecode_security_test.go new file mode 100644 index 0000000..3753f24 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_security_test.go @@ -0,0 +1,221 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestClaudeRejectsLinkedSettingsBeforeRuntimeWrites(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + target := filepath.Join(filepath.Dir(fixture.homeDir), "settings-target.json") + source := []byte("{\"owner\":\"protected\"}\n") + if err := os.MkdirAll(fixture.configDir, 0700); err != nil { + t.Fatalf("create Claude config directory: %v", err) + } + if err := os.WriteFile(target, source, 0600); err != nil { + t.Fatalf("write settings target: %v", err) + } + claudeSymlinkOrSkip(t, target, fixture.configPath) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "not a physical file") { + t.Fatalf("install error = %v", err) + } + current, readErr := os.ReadFile(target) + if readErr != nil || string(current) != string(source) { + t.Fatalf("linked target changed: %q, %v", current, readErr) + } + assertNoClaudeRuntimeWrites(t, fixture) +} + +func TestClaudeRejectsLinkedConfigAndRuntimeDirectories(t *testing.T) { + for _, kind := range []string{"config", "runtime"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + 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 == "config" { + linkPath = fixture.configDir + } else { + linkPath = filepath.Join(fixture.homeDir, ".elydora") + } + if err := os.MkdirAll(filepath.Dir(linkPath), 0700); err != nil { + t.Fatalf("create link parent: %v", err) + } + claudeSymlinkOrSkip(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 == "config" { + assertNoClaudeRuntimeWrites(t, fixture) + } else if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings exist after runtime rejection: %v", err) + } + }) + } +} + +func TestClaudeRejectsLinkedRuntimeArtifacts(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 := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude 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) + claudeSymlinkOrSkip(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 TestClaudeRejectsOrphanedAndMismatchedRuntimeIdentity(t *testing.T) { + for _, kind := range []string{"orphaned", "mismatched"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + 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":"claudecode","agent_id":"another-agent"}`), + 0600, + ); err != nil { + t.Fatalf("write mismatched config: %v", err) + } + } + err := fixture.plugin.Install(fixture.config) + want := "identity cannot be verified" + if kind == "mismatched" { + want = "identity does not match" + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("install error = %v, want %q", err, want) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings exist after identity rejection: %v", err) + } + }) + } +} + +func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) { + tests := []struct { + name string + edit func(*claudeFixture) + want string + }{ + {"agent name", func(f *claudeFixture) { f.config.AgentName = "codex" }, "requires agent name claudecode"}, + {"organization", func(f *claudeFixture) { f.config.OrgID = " " }, "organization ID is required"}, + {"key ID", func(f *claudeFixture) { f.config.KID = "\t" }, "key ID is required"}, + {"token", func(f *claudeFixture) { f.config.Token = " " }, "token must contain"}, + {"private key", func(f *claudeFixture) { f.config.PrivateKey = "invalid" }, "canonical 32-byte"}, + {"base URL", func(f *claudeFixture) { + f.config.BaseURL = "https://api.elydora.com/path?token=secret" + }, "query parameters"}, + {"guard path", func(f *claudeFixture) { + f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js") + }, "managed Elydora agent directory"}, + {"audit path", func(f *claudeFixture) { + f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js") + }, "managed Elydora agent directory"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + testCase.edit(fixture) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v, want %q", err, testCase.want) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("settings exist after input rejection: %v", err) + } + assertNoClaudeRuntimeWrites(t, fixture) + }) + } +} + +func TestClaudeStatusRejectsInvalidPrivateKey(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil { + t.Fatalf("corrupt private key: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || + !strings.Contains(err.Error(), "canonical 32-byte") { + t.Fatalf("invalid key status error = %v", err) + } +} + +func TestClaudeOwnershipRequiresExactGroupAndHandlerFields(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + guard := buildClaudeGroup(nodePath, fixture.guardPath, claudeGuardStatusMessage) + audit := buildClaudeGroup(nodePath, fixture.hookPath, claudeAuditStatusMessage) + audit.handlers[0]["once"] = false + writeClaudeTestObject(t, fixture.configPath, map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{map[string]any{ + "matcher": "Bash", "hooks": []any{guard.handlers[0]}, + }}, + "PostToolUse": []any{map[string]any{ + "hooks": []any{audit.handlers[0]}, + }}, + }, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil { + t.Fatalf("uninstall Claude hooks: %v", err) + } + hooks := requireObject(t, readClaudeTestObject(t, fixture.configPath)["hooks"]) + if len(requireArray(t, hooks["PreToolUse"])) != 1 || + len(requireArray(t, hooks["PostToolUse"])) != 1 { + t.Fatalf("ownership lookalikes changed: %#v", hooks) + } + if _, exists := hooks["PostToolUseFailure"]; exists { + t.Fatalf("managed failure hook remains: %#v", hooks) + } +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_status.go b/sdks/go/cmd/elydora/plugins/claudecode_status.go new file mode 100644 index 0000000..781cb21 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_status.go @@ -0,0 +1,231 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "sort" + "strings" +) + +func readClaudeRuntimeConfig(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 requireClaudeRuntimeString( + 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 validateClaudeRuntimeConfig( + 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 := requireClaudeRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameClaudeAgentID(agentID, expectedAgentID) || + config["agent_name"] != claudeAgentKey { + return fmt.Errorf( + "elydora runtime identity does not match Claude Code hooks: %s", + configPath, + ) + } + if _, exists := config["token"]; exists { + if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireClaudeRuntimeString(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 validateClaudeRuntimeIdentity(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 := readClaudeRuntimeConfig(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, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, claudeAuditScript), "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"] != claudeAgentKey || + !sameClaudeAgentID(configuredID, agentID) { + return fmt.Errorf( + "elydora runtime config identity does not match Claude Code agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) { + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return false, err + } + agentDirectory := filepath.Dir(contract.guardPath) + if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameClaudePath( + contract.auditPath, + filepath.Join(agentDirectory, claudeAuditScript), + ) { + 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 := readClaudeRuntimeConfig(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 := validateClaudeRuntimeConfig(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 claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) { + for _, contract := range contracts { + exists, err := claudeRuntimeContractExists(contract) + if err != nil { + return false, err + } + if exists { + return true, nil + } + } + return false, nil +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_test.go b/sdks/go/cmd/elydora/plugins/claudecode_test.go new file mode 100644 index 0000000..b5bbab3 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_test.go @@ -0,0 +1,332 @@ +package plugins + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestClaudeCodeRegistryUsesOfficialSettingsContract(t *testing.T) { + entry := SupportedAgents[claudeAgentKey] + if entry.Name != "Claude Code" || entry.ConfigDir != "$CLAUDE_CONFIG_DIR" || + entry.ConfigFile != claudeConfigFile { + t.Fatalf("Claude Code registry entry = %#v", entry) + } +} + +func TestClaudeInstallPreservesSettingsAndWritesExactTriple(t *testing.T) { + existing := `{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "model": "sonnet", + "disableAllHooks": false, + "hooks": { + "Notification": [{"matcher":"permission_prompt","hooks":[{"type":"http","url":"https://example.test/hook","timeout":1}] }], + "PreToolUse": [{"matcher":"Bash","hooks":[{"type":"command","command":"existing-command","timeout":5}]}] + } +} +` + fixture := prepareClaudeFixture( + t, + claudeFixtureOptions{existingRaw: &existing}, + ) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + settings := readClaudeTestObject(t, fixture.configPath) + if settings["model"] != "sonnet" || settings["disableAllHooks"] != false { + t.Fatalf("user settings changed: %#v", settings) + } + hooks := requireObject(t, settings["hooks"]) + if len(requireArray(t, hooks["Notification"])) != 1 || + len(requireArray(t, hooks["PreToolUse"])) != 2 { + t.Fatalf("user hooks changed: %#v", hooks) + } + requireStrictClaudeTriple(t, fixture) + first, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read installed settings: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat Claude install: %v", err) + } + second, err := os.ReadFile(fixture.configPath) + if err != nil || string(second) != string(first) { + t.Fatalf("idempotent settings = %q, %v", second, err) + } + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured || + !status.HookScriptExists { + t.Fatalf("Claude status = %#v, %v", status, err) + } +} + +func TestClaudeConfigDirMatchesOfficialResolution(t *testing.T) { + tests := []struct { + name string + present bool + configured string + expectedParts []string + }{ + {"default", false, "", []string{".claude", "settings.json"}}, + {"absolute", true, "ABSOLUTE", []string{"custom claude", "settings.json"}}, + {"relative", true, "relative claude", []string{"relative claude", "settings.json"}}, + {"empty", true, "", []string{"settings.json"}}, + {"literal tilde", true, "~", []string{"~", "settings.json"}}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + options := claudeFixtureOptions{configEnvPresent: testCase.present} + if testCase.configured == "ABSOLUTE" { + root := t.TempDir() + options.configEnvOverride = filepath.Join(root, "custom claude") + } else { + options.configEnvOverride = testCase.configured + } + fixture := prepareClaudeFixture(t, options) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + if _, err := os.Stat(fixture.configPath); err != nil { + t.Fatalf("resolved settings missing: %v", err) + } + for _, part := range testCase.expectedParts { + if !strings.Contains(fixture.configPath, part) { + t.Fatalf("settings path %q lacks %q", fixture.configPath, part) + } + } + }) + } +} + +func TestClaudeMalformedSettingsFailBeforeRuntimeCreation(t *testing.T) { + source := "{ malformed" + fixture := prepareClaudeFixture( + t, + claudeFixtureOptions{existingRaw: &source}, + ) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "parse Claude Code user settings") { + t.Fatalf("install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != source { + t.Fatalf("malformed settings changed: %q, %v", actual, readErr) + } + assertNoClaudeRuntimeWrites(t, fixture) +} + +func TestClaudePreservesEveryOfficialHandlerType(t *testing.T) { + source := `{"hooks":{ + "SessionStart":[{"hooks":[{"type":"prompt","prompt":"Review context","model":"haiku","continueOnBlock":true,"if":"always","once":false,"statusMessage":"Reviewing","timeout":0.5}]}], + "PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"user-command","args":["--safe"],"async":false,"asyncRewake":true,"shell":"powershell"}]}], + "Stop":[{"hooks":[{"type":"agent","prompt":"Verify completion","model":"sonnet"}]}], + "Notification":[{"hooks":[{"type":"http","url":"https://example.test/hook","headers":{"Authorization":"Bearer ${TOKEN}"},"allowedEnvVars":["TOKEN"]}]}], + "PostToolUse":[{"hooks":[{"type":"mcp_tool","server":"audit","tool":"record","input":{"source":"claude"}}]}] +}}` + var existing map[string]any + if err := json.Unmarshal([]byte(source), &existing); err != nil { + t.Fatalf("decode user hooks: %v", err) + } + userHooks := requireObject(t, existing["hooks"]) + fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + installed := requireObject(t, readClaudeTestObject(t, fixture.configPath)["hooks"]) + for event, groups := range userHooks { + actual := requireArray(t, installed[event])[0] + want := groups.([]any)[0] + if !reflect.DeepEqual(actual, want) { + t.Fatalf("%s user hook = %#v, want %#v", event, actual, want) + } + } +} + +func TestClaudeInvalidOfficialHookShapesFailBeforeWrites(t *testing.T) { + tests := []struct{ name, source, want string }{ + {"duplicate", `{"hooks":{},"hooks":{}}`, "duplicate key"}, + {"disable", `{"disableAllHooks":"yes"}`, "must be a boolean"}, + {"hooks", `{"hooks":null}`, "must be an object"}, + {"event", `{"hooks":{"MadeUp":[]}}`, "unsupported hook event"}, + {"event groups", `{"hooks":{"PreToolUse":null}}`, "must be an array"}, + {"group", `{"hooks":{"PreToolUse":[null]}}`, "must be an object"}, + {"group field", `{"hooks":{"PreToolUse":[{"hooks":[],"label":"x"}]}}`, "unsupported field"}, + {"matcher", `{"hooks":{"PreToolUse":[{"matcher":1,"hooks":[]}]}}`, "matcher must be a string"}, + {"group hooks", `{"hooks":{"PreToolUse":[{}]}}`, "hooks array"}, + {"handler", `{"hooks":{"PreToolUse":[{"hooks":[null]}]}}`, "handler"}, + {"type", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"file"}]}]}}`, "unsupported type"}, + {"handler field", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","invented":true}]}]}}`, "unsupported field"}, + {"command", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":""}]}]}}`, "non-empty string"}, + {"args", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","args":[1]}]}]}}`, "array of strings"}, + {"timeout", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"x","timeout":0}]}]}}`, "positive finite"}, + {"headers", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"http","url":"https://example.test","headers":{"A":1}}]}]}}`, "map names to strings"}, + {"input", `{"hooks":{"PreToolUse":[{"hooks":[{"type":"mcp_tool","server":"s","tool":"t","input":[]}]}]}}`, "must be an object"}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareClaudeFixture( + t, + claudeFixtureOptions{existingRaw: &testCase.source}, + ) + 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) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != testCase.source { + t.Fatalf("invalid settings changed: %q, %v", actual, readErr) + } + assertNoClaudeRuntimeWrites(t, fixture) + }) + } +} + +func TestClaudeDisabledHooksBlockInstallationAndHealth(t *testing.T) { + source := `{"disableAllHooks":true}` + fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source}) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "disabled by disableAllHooks") { + t.Fatalf("install error = %v", err) + } + status, statusErr := fixture.plugin.Status() + if statusErr != nil || status.Installed || status.HookConfigured { + t.Fatalf("disabled status = %#v, %v", status, statusErr) + } + assertNoClaudeRuntimeWrites(t, fixture) +} + +func TestClaudeExactLegacyHooksMigrateAndLookalikesSurvive(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + guardCommand := "node " + fixture.guardPath + auditCommand := "node " + fixture.hookPath + lookalike := guardCommand + " --inspect" + writeClaudeTestObject(t, fixture.configPath, map[string]any{ + "hooks": map[string]any{ + "PreToolUse": []any{ + map[string]any{"hooks": []any{map[string]any{ + "type": "command", "command": guardCommand, + }}}, + map[string]any{"hooks": []any{map[string]any{ + "type": "command", "command": lookalike, + }}}, + }, + "PostToolUse": []any{map[string]any{"hooks": []any{map[string]any{ + "type": "command", "command": auditCommand, + }}}}, + }, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("migrate Claude hooks: %v", err) + } + requireStrictClaudeTriple(t, fixture) + if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil { + t.Fatalf("uninstall Claude hooks: %v", err) + } + remaining := readClaudeTestObject(t, fixture.configPath) + hooks := requireObject(t, remaining["hooks"]) + groups := requireArray(t, hooks["PreToolUse"]) + if len(groups) != 1 { + t.Fatalf("legacy lookalikes = %#v", hooks) + } + handler := requireObject(t, requireArray(t, requireObject(t, groups[0])["hooks"])[0]) + if handler["command"] != lookalike { + t.Fatalf("legacy lookalike changed: %#v", handler) + } + if _, exists := hooks["PostToolUse"]; exists { + t.Fatalf("managed success hook remains: %#v", hooks) + } + if _, exists := hooks["PostToolUseFailure"]; exists { + t.Fatalf("managed failure hook remains: %#v", hooks) + } +} + +func TestClaudeUninstallPreservesUserSettings(t *testing.T) { + source := `{"model":"sonnet","hooks":{"Notification":[]}}` + fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil { + t.Fatalf("uninstall Claude hooks: %v", err) + } + settings := readClaudeTestObject(t, fixture.configPath) + want := map[string]any{ + "model": "sonnet", "hooks": map[string]any{"Notification": []any{}}, + } + if !reflect.DeepEqual(settings, want) { + t.Fatalf("remaining user settings = %#v", settings) + } +} + +func TestClaudeUninstallRemovesEntirelyManagedSettingsFile(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + if err := fixture.plugin.Uninstall(claudeTestAgentID); err != nil { + t.Fatalf("uninstall Claude hooks: %v", err) + } + if _, err := os.Lstat(fixture.configPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed settings remain: %v", err) + } +} + +func TestClaudeStatusRequiresEnabledUniqueTriple(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + settings := readClaudeTestObject(t, fixture.configPath) + hooks := requireObject(t, settings["hooks"]) + delete(hooks, "PostToolUseFailure") + writeClaudeTestObject(t, fixture.configPath, settings) + status, err := fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("incomplete status = %#v, %v", status, err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("restore Claude hooks: %v", err) + } + settings = readClaudeTestObject(t, fixture.configPath) + hooks = requireObject(t, settings["hooks"]) + groups := requireArray(t, hooks["PostToolUseFailure"]) + hooks["PostToolUseFailure"] = append(groups, groups[len(groups)-1]) + writeClaudeTestObject(t, fixture.configPath, settings) + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("duplicate status = %#v, %v", status, err) + } +} + +func TestClaudeProjectAndLocalSettingsStayUnchanged(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + projectSettings := filepath.Join(fixture.projectDir, ".claude", "settings.json") + localSettings := filepath.Join(fixture.projectDir, ".claude", "settings.local.json") + if err := os.MkdirAll(filepath.Dir(projectSettings), 0700); err != nil { + t.Fatalf("create project settings directory: %v", err) + } + projectSource := []byte(`{"hooks":{"PreToolUse":[]}}`) + localSource := []byte(`{"model":"haiku"}`) + if err := os.WriteFile(projectSettings, projectSource, 0600); err != nil { + t.Fatalf("write project settings: %v", err) + } + if err := os.WriteFile(localSettings, localSource, 0600); err != nil { + t.Fatalf("write local settings: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + for path, want := range map[string][]byte{ + projectSettings: projectSource, + localSettings: localSource, + } { + actual, err := os.ReadFile(path) + if err != nil || string(actual) != string(want) { + t.Fatalf("read-only source changed at %s: %q, %v", path, actual, err) + } + } +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/claudecode_test_helpers_test.go new file mode 100644 index 0000000..05c452f --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_test_helpers_test.go @@ -0,0 +1,315 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "testing" +) + +const ( + claudeTestAgentID = "agent-1" + claudePrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" +) + +type claudeFixtureOptions struct { + existingRaw *string + configEnvPresent bool + configEnvOverride string +} + +type claudeFixture struct { + plugin *ClaudeCodePlugin + config InstallConfig + homeDir string + projectDir string + configDir string + agentDir string + configPath string + guardPath string + hookPath string + runtimeConfig string + privateKey string +} + +func claudeString(value string) *string { + return &value +} + +func unsetClaudeConfigDir(t *testing.T) { + t.Helper() + value, existed := os.LookupEnv("CLAUDE_CONFIG_DIR") + if err := os.Unsetenv("CLAUDE_CONFIG_DIR"); err != nil { + t.Fatalf("unset CLAUDE_CONFIG_DIR: %v", err) + } + t.Cleanup(func() { + if existed { + _ = os.Setenv("CLAUDE_CONFIG_DIR", value) + } else { + _ = os.Unsetenv("CLAUDE_CONFIG_DIR") + } + }) +} + +func prepareClaudeFixture(t *testing.T, options claudeFixtureOptions) *claudeFixture { + t.Helper() + root := t.TempDir() + homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%") + projectDir := filepath.Join(root, "project with spaces") + if err := os.MkdirAll(projectDir, 0700); err != nil { + t.Fatalf("create project directory: %v", err) + } + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatalf("read current directory: %v", err) + } + if err := os.Chdir(projectDir); err != nil { + t.Fatalf("enter project directory: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + var configDir string + if options.configEnvPresent { + t.Setenv("CLAUDE_CONFIG_DIR", options.configEnvOverride) + configDir, err = filepath.Abs(options.configEnvOverride) + if err != nil { + t.Fatalf("resolve fixture config directory: %v", err) + } + } else { + unsetClaudeConfigDir(t) + configDir = filepath.Join(homeDir, ".claude") + } + agentDir := filepath.Join(homeDir, ".elydora", claudeTestAgentID) + configPath := filepath.Join(configDir, claudeConfigFile) + if options.existingRaw != nil { + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatalf("create Claude config directory: %v", err) + } + if err := os.WriteFile(configPath, []byte(*options.existingRaw), 0600); err != nil { + t.Fatalf("write Claude settings: %v", err) + } + } + guardPath := filepath.Join(agentDir, claudeGuardScript) + return &claudeFixture{ + plugin: &ClaudeCodePlugin{}, + config: InstallConfig{ + AgentName: claudeAgentKey, OrgID: "org-1", AgentID: claudeTestAgentID, + PrivateKey: claudePrivateKey, KID: "kid-1", Token: "token-1", + BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, + }, + homeDir: homeDir, projectDir: projectDir, configDir: configDir, + agentDir: agentDir, configPath: configPath, guardPath: guardPath, + hookPath: filepath.Join(agentDir, claudeAuditScript), + runtimeConfig: filepath.Join(agentDir, "config.json"), + privateKey: filepath.Join(agentDir, "private.key"), + } +} + +func readClaudeTestObject(t *testing.T, path string) map[string]any { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +func writeClaudeTestObject(t *testing.T, path string, value map[string]any) { + t.Helper() + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("marshal Claude settings: %v", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create settings directory: %v", err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatalf("write Claude settings: %v", err) + } +} + +func claudeTestManagedHandler( + t *testing.T, + settings map[string]any, + event string, + scriptName string, + statusMessage string, +) map[string]any { + t.Helper() + hooks := requireObject(t, settings["hooks"]) + for _, groupValue := range requireArray(t, hooks[event]) { + group := requireObject(t, groupValue) + if len(group) != 1 { + continue + } + for _, handlerValue := range requireArray(t, group["hooks"]) { + handler := requireObject(t, handlerValue) + reference, err := managedClaudeReference( + handler, + scriptName, + statusMessage, + false, + ) + if err != nil { + t.Fatalf("inspect managed %s handler: %v", event, err) + } + if reference != nil { + return handler + } + } + } + t.Fatalf("managed %s handler for %s not found", event, scriptName) + return nil +} + +func requireStrictClaudeTriple(t *testing.T, fixture *claudeFixture) { + t.Helper() + settings := readClaudeTestObject(t, fixture.configPath) + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + for _, item := range []struct{ event, script, path, status string }{ + {"PreToolUse", claudeGuardScript, fixture.guardPath, claudeGuardStatusMessage}, + {"PostToolUse", claudeAuditScript, fixture.hookPath, claudeAuditStatusMessage}, + {"PostToolUseFailure", claudeAuditScript, fixture.hookPath, claudeAuditStatusMessage}, + } { + handler := claudeTestManagedHandler( + t, + settings, + item.event, + item.script, + item.status, + ) + want := map[string]any{ + "type": "command", "command": nodePath, "args": []any{item.path}, + "timeout": float64(10), "statusMessage": item.status, + } + if !reflect.DeepEqual(handler, want) { + t.Fatalf("%s handler = %#v, want %#v", item.event, handler, want) + } + } +} + +func claudeOfficialPayload(event string) map[string]any { + payload := map[string]any{ + "session_id": "session-1", "transcript_path": "C:/transcript.jsonl", + "cwd": "C:/project", "permission_mode": "default", + "hook_event_name": event, "tool_name": "Bash", + "tool_input": map[string]any{"command": "echo test"}, + "tool_use_id": "call-1", + } + if event == "PostToolUse" { + payload["tool_response"] = map[string]any{"output": "test", "success": true} + } + if event == "PostToolUseFailure" { + payload["error"] = "command failed" + payload["is_interrupt"] = false + } + return payload +} + +func runClaudeHandler( + t *testing.T, + handler map[string]any, + fixture *claudeFixture, + payload map[string]any, +) (int, string, string) { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Claude payload: %v", err) + } + return runClaudeRawHandler(t, handler, fixture, encoded) +} + +func runClaudeRawHandler( + t *testing.T, + handler map[string]any, + fixture *claudeFixture, + input []byte, +) (int, string, string) { + t.Helper() + command, ok := handler["command"].(string) + if !ok { + t.Fatalf("Claude command = %#v", handler["command"]) + } + values := requireArray(t, handler["args"]) + args := make([]string, 0, len(values)) + for _, value := range values { + argument, ok := value.(string) + if !ok { + t.Fatalf("Claude argument = %#v", value) + } + args = append(args, argument) + } + process := exec.Command(command, args...) + process.Dir = fixture.projectDir + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + ) + process.Stdin = bytes.NewReader(input) + 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 Claude handler: %v", err) + return -1, stdout.String(), stderr.String() +} + +func assertNoClaudeRuntimeWrites(t *testing.T, fixture *claudeFixture) { + 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 assertNoClaudeTransactionArtifacts(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 + } + name := entry.Name() + if filepath.Ext(name) == ".tmp" || filepath.Ext(name) == ".rollback" { + t.Errorf("transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk Claude fixture: %v", err) + } +} + +func claudeSymlinkOrSkip(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go b/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go new file mode 100644 index 0000000..b46991b --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/claudecode_transaction_test.go @@ -0,0 +1,194 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func snapshotClaudeFiles(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 requireClaudeSnapshot(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 TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) { + fixture := prepareClaudeFixture(t, claudeFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + settings := readClaudeTestObject(t, fixture.configPath) + settings["owner"] = "user" + delete(requireObject(t, settings["hooks"]), "PostToolUseFailure") + writeClaudeTestObject(t, fixture.configPath, settings) + staleConfig := `{"org_id":"old","agent_id":"agent-1","kid":"old","base_url":"https://old.test","agent_name":"claudecode"}` + for _, item := range []struct{ path, source string }{ + {fixture.guardPath, "stale guard\n"}, + {fixture.runtimeConfig, staleConfig}, + {fixture.privateKey, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, + {fixture.hookPath, "stale audit\n"}, + } { + if err := os.WriteFile(item.path, []byte(item.source), 0600); err != nil { + t.Fatalf("write stale runtime %s: %v", item.path, err) + } + } + paths := []string{ + fixture.guardPath, + fixture.runtimeConfig, + fixture.privateKey, + fixture.hookPath, + fixture.configPath, + } + before := snapshotClaudeFiles(t, paths...) + fixture.plugin.rename = func(source, destination string) error { + if sameClaudePath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Claude settings failure") + } + return os.Rename(source, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected Claude settings failure") { + t.Fatalf("install error = %v", err) + } + requireClaudeSnapshot(t, before) + assertNoClaudeTransactionArtifacts(t, fixture.homeDir) +} + +func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) { + source := `{"owner":"original"}` + fixture := prepareClaudeFixture( + t, + claudeFixtureOptions{existingRaw: &source}, + ) + document, err := readClaudeDocument() + if err != nil { + t.Fatalf("read Claude document: %v", err) + } + paths, nodePath, err := preflightClaudeInstallation(fixture.config, document) + if err != nil { + t.Fatalf("preflight Claude installation: %v", err) + } + hooks, err := removeManagedClaudeHooks(document.hooks, "") + if err != nil { + t.Fatalf("remove managed Claude hooks: %v", err) + } + for _, item := range []struct{ event, script, status string }{ + {"PreToolUse", paths.guardPath, claudeGuardStatusMessage}, + {"PostToolUse", paths.auditPath, claudeAuditStatusMessage}, + {"PostToolUseFailure", paths.auditPath, claudeAuditStatusMessage}, + } { + hooks[item.event] = append( + hooks[item.event], + buildClaudeGroup(nodePath, item.script, item.status), + ) + } + rendered, err := renderClaudeDocument(document, hooks) + if err != nil { + t.Fatalf("render Claude document: %v", err) + } + changes, err := prepareClaudeInstallationChanges(fixture.config, paths, rendered) + if err != nil { + t.Fatalf("prepare Claude installation: %v", err) + } + concurrent := []byte(`{"owner":"concurrent"}`) + if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil { + t.Fatalf("write concurrent settings: %v", err) + } + err = writeClaudeChanges( + changes, + "Install Claude Code 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.configPath) + if readErr != nil || string(actual) != string(concurrent) { + t.Fatalf("concurrent settings changed: %q, %v", actual, readErr) + } + assertNoClaudeRuntimeWrites(t, fixture) + assertNoClaudeTransactionArtifacts(t, fixture.homeDir) +} + +func TestClaudeInstallDetectsConcurrentSettingsIdentityReplacement(t *testing.T) { + source := []byte(`{"owner":"original"}`) + fixture := prepareClaudeFixture( + t, + claudeFixtureOptions{existingRaw: claudeString(string(source))}, + ) + mutated := false + fixture.plugin.rename = func(stagedPath, destination string) error { + if !mutated && strings.HasSuffix(stagedPath, ".tmp") { + mutated = true + external := fixture.configPath + ".external" + if err := os.Rename(fixture.configPath, external); err != nil { + return err + } + if err := os.WriteFile(fixture.configPath, source, 0600); err != nil { + return err + } + } + return os.Rename(stagedPath, destination) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("concurrent identity error = %v", err) + } + actual, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(actual) != string(source) { + t.Fatalf("replacement settings changed: %q, %v", actual, readErr) + } + assertNoClaudeRuntimeWrites(t, fixture) + assertNoClaudeTransactionArtifacts(t, fixture.homeDir) +} + +func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) { + source := `{"owner":"user"}` + fixture := prepareClaudeFixture(t, claudeFixtureOptions{existingRaw: &source}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Claude hooks: %v", err) + } + before, err := os.ReadFile(fixture.configPath) + if err != nil { + t.Fatalf("read installed settings: %v", err) + } + fixture.plugin.rename = func(source, destination string) error { + if sameClaudePath(destination, fixture.configPath) && + strings.HasSuffix(source, ".tmp") { + return errors.New("injected Claude uninstall failure") + } + return os.Rename(source, destination) + } + err = fixture.plugin.Uninstall(claudeTestAgentID) + if err == nil || !strings.Contains(err.Error(), "injected Claude uninstall failure") { + t.Fatalf("uninstall error = %v", err) + } + after, readErr := os.ReadFile(fixture.configPath) + if readErr != nil || string(after) != string(before) { + t.Fatalf("Claude settings changed: %q, %v", after, readErr) + } + assertNoClaudeTransactionArtifacts(t, fixture.homeDir) +} diff --git a/sdks/go/cmd/elydora/plugins/legacy_hook_entries.go b/sdks/go/cmd/elydora/plugins/legacy_hook_entries.go new file mode 100644 index 0000000..1fdd28d --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/legacy_hook_entries.go @@ -0,0 +1,73 @@ +package plugins + +import "strings" + +// extractElydoraScriptPath supports adapters that still use the legacy shared +// command-hook shape. Provider-specific adapters should define exact ownership. +func extractElydoraScriptPath(hookArray any) string { + entries, _ := hookArray.([]any) + for _, entry := range entries { + object, ok := entry.(map[string]any) + if !ok { + continue + } + if hooks, ok := object["hooks"].([]any); ok { + for _, hook := range hooks { + handler, ok := hook.(map[string]any) + if !ok { + continue + } + if command, _ := handler["command"].(string); strings.Contains( + command, + "elydora", + ) { + return extractPathFromNodeCommand(command) + } + } + } + if command, _ := object["command"].(string); strings.Contains( + command, + "elydora", + ) { + return extractPathFromNodeCommand(command) + } + } + return "" +} + +func extractPathFromNodeCommand(command string) string { + command = strings.TrimSpace(command) + if strings.HasPrefix(command, "node ") { + return strings.TrimSpace(command[5:]) + } + return "" +} + +func hasElydoraEntry(hookArray any) bool { + entries, _ := hookArray.([]any) + for _, entry := range entries { + if object, ok := entry.(map[string]any); ok && isElydoraHookEntry(object) { + return true + } + } + return false +} + +func isElydoraHookEntry(object map[string]any) bool { + if hooks, ok := object["hooks"].([]any); ok { + for _, hook := range hooks { + handler, ok := hook.(map[string]any) + if !ok { + continue + } + if command, _ := handler["command"].(string); strings.Contains( + command, + "elydora", + ) { + return true + } + } + } + command, _ := object["command"].(string) + return strings.Contains(command, "elydora") +} diff --git a/sdks/go/cmd/elydora/plugins/registry.go b/sdks/go/cmd/elydora/plugins/registry.go index b58f7b2..40feea6 100644 --- a/sdks/go/cmd/elydora/plugins/registry.go +++ b/sdks/go/cmd/elydora/plugins/registry.go @@ -10,7 +10,7 @@ type AgentRegistryEntry struct { // SupportedAgents is the canonical registry of all supported agent integrations. var SupportedAgents = map[string]AgentRegistryEntry{ "augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"}, - "claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"}, + "claudecode": {Name: "Claude Code", ConfigDir: "$CLAUDE_CONFIG_DIR", ConfigFile: "settings.json"}, "codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"}, "cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},