fix(claude): implement official Go hook contract

This commit is contained in:
ZacharyZhang-NY committed 2026-07-20 07:23:12 -04:00
1 parent f71db95502
commit 15d044cf63
17 files changed
+3003 -233

No files matched your search

+2
View File
@@ -27,6 +27,8 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh
- Select Kimi Code from an explicit `KIMI_CODE_HOME` or an existing `~/.kimi-code` home, and select legacy `kimi-cli` from an existing `~/.kimi` home. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; executable lookup is outside the activation contract, and equal stable and legacy config paths collapse to the stable contract. - Select Kimi Code from an explicit `KIMI_CODE_HOME` or an existing `~/.kimi-code` home, and select legacy `kimi-cli` from an existing `~/.kimi` home. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; executable lookup is outside the activation contract, and equal stable and legacy config paths collapse to the stable contract.
- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. Own only exact three-field hooks for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; require one complete triple per runtime identity, use encoded PowerShell commands on Windows, and retain the native snake_case payload. - Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. Own only exact three-field hooks for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; require one complete triple per runtime identity, use encoded PowerShell commands on Windows, and retain the native snake_case payload.
- Generate Kimi guard, config, private key, and audit runtimes together with every selected TOML document in one rollback-capable transaction. Preflight physical paths and existing runtime identity before creating state; status validates the complete runtime metadata, canonical private key, exact agent identity, and non-empty physical scripts. - Generate Kimi guard, config, private key, and audit runtimes together with every selected TOML document in one rollback-capable transaction. Preflight physical paths and existing runtime identity before creating state; status validates the complete runtime metadata, canonical private key, exact agent identity, and non-empty physical scripts.
- Write Claude Code hooks only to `$CLAUDE_CONFIG_DIR/settings.json` with `~/.claude/settings.json` as the default; keep project, local, managed, plugin, skill, and agent sources read-only. Preserve the official exec-form command schema and native snake_case payload, own one exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` triple, propagate freeze and revocation through exit code `2`, and treat `disableAllHooks` as an explicit installation and health gate.
- Generate Claude Code guard, config, private key, and audit runtimes with user settings in one rollback-capable transaction. Validate the full official event and handler schema before writes, migrate only the exact prior Go command contract, require strict runtime identity for health, and direct users to `/hooks` plus `claude doctor` for effective-source verification.
- Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat project `.grok/hooks`, plugin hooks, Claude Code, and Cursor compatibility files as read-only integration sources. Own only exact matchless three-field command groups for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; accept official non-negative integer timeouts and string-valued environments; reject lifecycle matchers; and preserve native camelCase payloads. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat project `.grok/hooks`, plugin hooks, Claude Code, and Cursor compatibility files as read-only integration sources. Own only exact matchless three-field command groups for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; accept official non-negative integer timeouts and string-valued environments; reject lifecycle matchers; and preserve native camelCase payloads.
- Generate Grok guard, config, private key, and audit runtimes with the user hook document in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit native deny JSON with exit code `2`, preflight physical paths and runtime identity, and require one exact event triple plus strict runtime metadata and a canonical private key for healthy status. - Generate Grok guard, config, private key, and audit runtimes with the user hook document in one rollback-capable transaction. Use encoded PowerShell commands on Windows, emit native deny JSON with exit code `2`, preflight physical paths and runtime identity, and require one exact event triple plus strict runtime metadata and a canonical private key for healthy status.
- Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds.
+3 -1
View File
@@ -77,7 +77,9 @@ elydora install \
--kid agent-456-key-v1 --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. 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.
+98 -231
View File
@@ -2,280 +2,147 @@ package plugins
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
) )
// ClaudeCodePlugin manages the Elydora audit hook for Claude Code. // ClaudeCodePlugin manages Claude Code global user hooks.
// It merges a PostToolUse hook into ~/.claude/settings.json. type ClaudeCodePlugin struct {
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 { func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
scriptPath, err := hookScriptPath(config.AgentID) document, err := readClaudeDocument()
if err != nil { if err != nil {
return err return err
} }
if config.HookScript != "" { if err := requireClaudeHooksEnabled(document); err != nil {
scriptPath = config.HookScript return err
} }
paths, nodePath, err := preflightClaudeInstallation(config, document)
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 { if err != nil {
return err return err
} }
} hooks, err := removeManagedClaudeHooks(document.hooks, "")
configDir, err := expandHome("~/.claude")
if err != nil { if err != nil {
return err return err
} }
configPath := filepath.Join(configDir, "settings.json") for _, item := range []struct{ event, script, status string }{
{"PreToolUse", paths.guardPath, claudeGuardStatusMessage},
settings, err := readJSONFile(configPath) {"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 {
return fmt.Errorf("render Claude Code user settings: %w", err)
}
changes, err := prepareClaudeInstallationChanges(config, paths, rendered)
if err != nil { if err != nil {
return err return err
} }
if err := writeClaudeChanges(
hooks, _ := settings["hooks"].(map[string]interface{}) changes,
if hooks == nil { "Install Claude Code hooks",
hooks = make(map[string]interface{}) p.rename,
} paths.runtimeRoot,
paths.agentDirectory,
// --- PreToolUse (guard — freeze enforcement) --- filepath.Dir(document.filePath),
preToolUse, _ := hooks["PreToolUse"].([]interface{}) ); err != nil {
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
}
}
preFiltered = append(preFiltered, entry)
}
guardEntry := map[string]interface{}{
"hooks": []interface{}{
map[string]interface{}{
"type": "command",
"command": "node " + guardPath,
},
},
}
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 {
return err return err
} }
fmt.Printf("Installed Elydora hook for Claude Code at %s\n", configPath) fmt.Printf("Claude Code hooks installed at %s.\n", document.filePath)
fmt.Println("Claude Code verification: run /hooks and claude doctor.")
return nil return nil
} }
func (p *ClaudeCodePlugin) Uninstall(agentID string) error { func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
configDir, err := expandHome("~/.claude") document, err := readClaudeDocument()
if err != nil { if err != nil {
return err return err
} }
configPath := filepath.Join(configDir, "settings.json") hooks, err := removeManagedClaudeHooks(document.hooks, agentID)
settings, err := readJSONFile(configPath)
if err != nil { if err != nil {
return err return err
} }
rendered, err := renderClaudeDocument(document, hooks)
hooks, _ := settings["hooks"].(map[string]interface{}) if err != nil {
if hooks == nil { return fmt.Errorf("render Claude Code user settings: %w", err)
fmt.Println("No Claude Code hooks found.")
return nil
} }
change, err := prepareRenderedClaudeChange(rendered)
// Remove PreToolUse Elydora entries if err != nil {
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 {
return err return err
} }
return writeClaudeChanges(
if agentID != "" { []*fileChange{change},
scriptPath, _ := hookScriptPath(agentID) "Uninstall Claude Code hooks",
if scriptPath != "" { p.rename,
os.Remove(scriptPath) "",
} "",
gPath, _ := guardScriptPath(agentID) filepath.Dir(document.filePath),
if gPath != "" { )
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Claude Code.")
return nil
} }
func (p *ClaudeCodePlugin) Status() (PluginStatus, error) { func (p *ClaudeCodePlugin) Status() (PluginStatus, error) {
configDir, err := expandHome("~/.claude") configPath, pathErr := claudeSettingsPath()
if err != nil { entry := SupportedAgents[claudeAgentKey]
return PluginStatus{}, err
}
configPath := filepath.Join(configDir, "settings.json")
status := PluginStatus{ status := PluginStatus{
AgentName: "claudecode", AgentName: claudeAgentKey, DisplayName: entry.Name, ConfigPath: configPath,
DisplayName: "Claude Code",
ConfigPath: configPath,
} }
if pathErr != nil {
// Check if hook is configured in settings and extract hook script path return status, pathErr
settings, err := readJSONFile(configPath) }
document, err := readClaudeDocument()
if err != nil { if err != nil {
return status, err
}
if document.hooksDisabled {
return status, nil return status, nil
} }
contracts, err := claudeRuntimeContracts(document.hooks)
hooks, _ := settings["hooks"].(map[string]interface{}) if err != nil {
if hooks != nil { return status, err
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
} }
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
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil 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
}
+117
View File
@@ -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)
}
}
+453
View File
@@ -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
}
@@ -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
}
+108
View File
@@ -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)
}
@@ -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)
}
}
}
+326
View File
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
+231
View File
@@ -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
}
+332
View File
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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")
}
+1 -1
View File
@@ -10,7 +10,7 @@ type AgentRegistryEntry struct {
// SupportedAgents is the canonical registry of all supported agent integrations. // SupportedAgents is the canonical registry of all supported agent integrations.
var SupportedAgents = map[string]AgentRegistryEntry{ var SupportedAgents = map[string]AgentRegistryEntry{
"augment": {Name: "Augment Code CLI", ConfigDir: "~/.augment", ConfigFile: "settings.json"}, "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"}, "codex": {Name: "OpenAI Codex", ConfigDir: "~/.codex", ConfigFile: "hooks.json"},
"cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"}, "cline": {Name: "Cline", ConfigDir: "~/.cline/hooks", ConfigFile: "PreToolUse.mjs"},
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},