fix(go): mirror official Claude hooks
This commit is contained in:
1 parent
c0534bf1de
commit
48a830ff4d
16 files changed
+3004
-236
No files matched your search
+3
-1
@@ -74,7 +74,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
configDir, err := expandHome("~/.claude")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(configDir, "settings.json")
|
hooks, err := removeManagedClaudeHooks(document.hooks, "")
|
||||||
|
|
||||||
settings, err := readJSONFile(configPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, item := range []struct{ event, script, status string }{
|
||||||
hooks, _ := settings["hooks"].(map[string]interface{})
|
{"PreToolUse", paths.guardPath, claudeGuardStatusMessage},
|
||||||
if hooks == nil {
|
{"PostToolUse", paths.auditPath, claudeAuditStatusMessage},
|
||||||
hooks = make(map[string]interface{})
|
{"PostToolUseFailure", paths.auditPath, claudeAuditStatusMessage},
|
||||||
|
} {
|
||||||
|
hooks[item.event] = append(
|
||||||
|
hooks[item.event],
|
||||||
|
buildClaudeGroup(nodePath, item.script, item.status),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
rendered, err := renderClaudeDocument(document, hooks)
|
||||||
// --- PreToolUse (guard — freeze enforcement) ---
|
if err != nil {
|
||||||
preToolUse, _ := hooks["PreToolUse"].([]interface{})
|
return fmt.Errorf("render Claude Code user settings: %w", err)
|
||||||
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{}{
|
changes, err := prepareClaudeInstallationChanges(config, paths, rendered)
|
||||||
"hooks": []interface{}{
|
if err != nil {
|
||||||
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)
|
if err := writeClaudeChanges(
|
||||||
|
changes,
|
||||||
|
"Install Claude Code hooks",
|
||||||
|
p.rename,
|
||||||
|
paths.runtimeRoot,
|
||||||
|
paths.agentDirectory,
|
||||||
|
filepath.Dir(document.filePath),
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Claude Code hooks installed at %s.\n", document.filePath)
|
||||||
|
fmt.Println("Claude Code verification: run /hooks and claude doctor.")
|
||||||
return nil
|
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
|
||||||
status.Installed = status.HookConfigured && status.HookScriptExists
|
if !status.HookConfigured {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
status.HookScriptExists, err = claudeRuntimeFilesExist(contracts)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
status.Installed = 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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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"},
|
||||||
|
|||||||
Reference in new issue
Block a user