fix(claude): implement official Go hook contract
This commit is contained in:
1 parent
f71db95502
commit
15d044cf63
17 files changed
+3006
-236
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user