From 758d129dbf6ef03e81de7c7bf8fed5bfffff66b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Jul 2026 04:58:54 -0400 Subject: [PATCH] feat: support Kiro CLI v2 and v3 hooks --- AGENTS.md | 51 +++ README.md | 2 + cmd/elydora/plugins/kirocli.go | 314 ++++++++--------- cmd/elydora/plugins/kirocli_contract.go | 399 ++++++++++++++++++++++ cmd/elydora/plugins/kirocli_io.go | 142 ++++++++ cmd/elydora/plugins/kirocli_test.go | 436 ++++++++++++++++++++++++ cmd/elydora/plugins/registry.go | 2 +- 7 files changed, 1175 insertions(+), 171 deletions(-) create mode 100644 AGENTS.md create mode 100644 cmd/elydora/plugins/kirocli_contract.go create mode 100644 cmd/elydora/plugins/kirocli_io.go create mode 100644 cmd/elydora/plugins/kirocli_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a572371 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# Elydora Go SDK Engineering Contract + +## Scope + +This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing behavior, generated Node.js hook runtimes, and Go-specific agent adapters. + +## Integration Sources + +- Verify every agent hook contract against current official provider documentation before changing an adapter. +- Treat `../Elydora-Open-Source/integrations/catalog.json` as the cross-product provider inventory. +- Keep adapter delivery claims aligned with executable tests in this repository. +- Synchronize completed provider behavior into the Node SDK, Python SDK, Open Source distribution, Console, Docs, and landing page through separate reviewed commits. + +## Hook Adapter Invariants + +- Preserve unrelated user configuration and remove only Elydora-owned entries. +- Parse every affected user configuration before the first write. +- Surface malformed or unreadable configuration with contextual errors and leave the original file intact. +- Write configuration through a same-directory temporary file, flush it, and atomically replace the target. +- Resolve and quote the Node.js runtime and generated script paths for the host shell. +- Forward official hook JSON from STDIN without reshaping provider fields. +- Use the provider's documented blocking mechanism. Command-hook providers that define exit code `2` must receive exit code `2` from the freeze guard. +- Report installation as healthy only when a complete hook contract references both generated runtime scripts and both scripts exist. +- Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. + +## Code Quality + +- Preserve the minimum Go version declared in `go.mod`. +- Keep production source files at or below 500 lines. +- Keep functions focused on one ownership boundary. +- Return unexpected errors to the CLI boundary with operation and path context. +- Use documented defaults only for genuinely optional configuration. +- Avoid compatibility shims without a named public or user configuration contract. + +## Verification + +Run the focused adapter test during development, then execute all release gates before commit: + +```powershell +go test ./cmd/elydora/plugins -run -count=1 +go test ./... +go test -race ./... +go vet ./... +go build ./cmd/elydora +govulncheck ./... +git diff --check +``` + +Provider adapter tests must cover installation, idempotency, official event forwarding, blocking behavior, status, missing runtime files, uninstall ownership, and malformed configuration preservation. + +Commit and push one root issue before starting the next one. diff --git a/README.md b/README.md index adaa136..acede7e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ elydora install \ --kid agent-456-key-v1 ``` +Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`. + ### Commands | Command | Description | diff --git a/cmd/elydora/plugins/kirocli.go b/cmd/elydora/plugins/kirocli.go index 61d6aa2..a8d0a86 100644 --- a/cmd/elydora/plugins/kirocli.go +++ b/cmd/elydora/plugins/kirocli.go @@ -1,24 +1,46 @@ package plugins -import ( - "fmt" - "os" - "path/filepath" -) +import "fmt" -// KiroCliPlugin manages the Elydora audit hook for Kiro CLI. -// It merges PreToolUse/PostToolUse hooks into ~/.kiro/settings.json. +// KiroCliPlugin manages the stable v2 custom-agent and early-access v3 hook contracts. type KiroCliPlugin struct{} -func (p *KiroCliPlugin) configPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolve home directory: %w", err) - } - return filepath.Join(home, ".kiro", "settings.json"), nil -} - func (p *KiroCliPlugin) Install(config InstallConfig) error { + if config.AgentID == "" { + return fmt.Errorf("agent ID is required") + } + if config.GuardScriptPath == "" { + return fmt.Errorf("guard script path is required") + } + guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") + if err != nil { + return err + } + if !guardExists { + return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) + } + + v2Path, v3Path, err := kiroConfigPaths() + if err != nil { + return err + } + v2Settings, _, err := readKiroObject(v2Path, "Kiro CLI v2 agent config") + if err != nil { + return err + } + v3Settings, _, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config") + if err != nil { + return err + } + v2Hooks, err := kiroHooksObject(v2Settings, "Kiro CLI v2 agent config") + if err != nil { + return err + } + currentV3Hooks, err := kiroV3Hooks(v3Settings) + if err != nil { + return err + } + scriptPath, err := hookScriptPath(config.AgentID) if err != nil { return err @@ -26,199 +48,151 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error { if config.HookScript != "" { scriptPath = config.HookScript } + nodePath, err := resolveNodeRuntime() + if err != nil { + return err + } + + preToolUse, err := kiroHookEntries(v2Hooks, "preToolUse", "Kiro CLI v2 agent config") + if err != nil { + return err + } + postToolUse, err := kiroHookEntries(v2Hooks, "postToolUse", "Kiro CLI v2 agent config") + if err != nil { + return err + } + v2Hooks["preToolUse"] = append( + withoutKiroV2Hooks(preToolUse, ""), + buildKiroV2Hook(nodePath, config.GuardScriptPath), + ) + v2Hooks["postToolUse"] = append( + withoutKiroV2Hooks(postToolUse, ""), + buildKiroV2Hook(nodePath, scriptPath), + ) + + nextV2 := map[string]any{ + "name": kiroV2AgentName, + "description": kiroV2Description, + "tools": []any{"*"}, + "includeMcpJson": true, + } + copyKiroObject(nextV2, v2Settings) + nextV2["hooks"] = v2Hooks + + nextV3 := cloneKiroObject(v3Settings) + nextV3["version"] = "v1" + v3Hooks := make([]any, 0, len(currentV3Hooks)+2) + for _, hook := range currentV3Hooks { + if !isManagedKiroV3Hook(hook, "") { + v3Hooks = append(v3Hooks, hook) + } + } + v3Hooks = append( + v3Hooks, + buildKiroV3Hook( + kiroV3GuardName, + "Block tool use when the Elydora agent is frozen", + "PreToolUse", + nodePath, + config.GuardScriptPath, + ), + buildKiroV3Hook( + kiroV3AuditName, + "Record tool use in the Elydora audit trail", + "PostToolUse", + nodePath, + scriptPath, + ), + ) + nextV3["hooks"] = v3Hooks 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 - } + if err := writeKiroObjectAtomic(v2Path, nextV2); err != nil { + return err } - - configPath, err := p.configPath() - if err != nil { + if err := writeKiroObjectAtomic(v3Path, nextV3); err != nil { return err } - settings, err := readJSONFile(configPath) - if err != nil { - return err - } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - hooks = make(map[string]interface{}) - } - - // --- PreToolUse (guard — freeze enforcement) --- - preToolUse, _ := hooks["PreToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) - } - guardEntry := map[string]interface{}{ - "matcher": "*", - "hooks": []interface{}{ - map[string]interface{}{ - "type": "command", - "command": "node " + guardPath, - "timeout_ms": float64(5000), - }, - }, - } - 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{}{ - "matcher": "*", - "hooks": []interface{}{ - map[string]interface{}{ - "type": "command", - "command": "node " + scriptPath, - "timeout_ms": float64(5000), - }, - }, - } - postFiltered = append(postFiltered, hookEntry) - hooks["PostToolUse"] = postFiltered - - settings["hooks"] = hooks - - if err := writeJSONFile(configPath, settings); err != nil { - return err - } - fmt.Printf("Installed Elydora hook for Kiro CLI at %s\n", configPath) + fmt.Println(`Kiro CLI v2: start with "kiro-cli --agent elydora-audit".`) + fmt.Println(`Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.`) return nil } func (p *KiroCliPlugin) Uninstall(agentID string) error { - configPath, err := p.configPath() + v2Path, v3Path, err := kiroConfigPaths() + if err != nil { + return err + } + v2Settings, v2Exists, err := readKiroObject(v2Path, "Kiro CLI v2 agent config") + if err != nil { + return err + } + v3Settings, v3Exists, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config") if err != nil { return err } - settings, err := readJSONFile(configPath) + v2Mutation, err := prepareKiroV2Uninstall(v2Path, v2Settings, v2Exists, agentID) if err != nil { return err } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks == nil { - fmt.Println("No Kiro CLI hooks found.") - return nil + v3Mutation, err := prepareKiroV3Uninstall(v3Path, v3Settings, v3Exists, agentID) + if err != nil { + return err } - - // Remove PreToolUse Elydora entries - preToolUse, _ := hooks["PreToolUse"].([]interface{}) - var preFiltered []interface{} - for _, entry := range preToolUse { - if m, ok := entry.(map[string]interface{}); ok { - if isElydoraHookEntry(m) { - continue - } - } - preFiltered = append(preFiltered, entry) + if err := applyKiroMutation(v2Mutation); err != nil { + return err } - 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 { + if err := applyKiroMutation(v3Mutation); err != nil { return err } - if agentID != "" { - scriptPath, _ := hookScriptPath(agentID) - if scriptPath != "" { - os.Remove(scriptPath) - } - gPath, _ := guardScriptPath(agentID) - if gPath != "" { - os.Remove(gPath) - } - } - fmt.Println("Uninstalled Elydora hook for Kiro CLI.") + fmt.Println("Uninstalled Elydora hooks from Kiro CLI.") return nil } func (p *KiroCliPlugin) Status() (PluginStatus, error) { - configPath, err := p.configPath() + v2Path, v3Path, err := kiroConfigPaths() if err != nil { return PluginStatus{}, err } - status := PluginStatus{ - AgentName: "kirocli", + AgentName: kiroAgentKey, DisplayName: "Kiro CLI", - ConfigPath: configPath, + ConfigPath: v3Path, + } + v2Settings, v2Exists, err := readKiroObject(v2Path, "Kiro CLI v2 agent config") + if err != nil { + return status, err + } + v3Settings, v3Exists, err := readKiroObject(v3Path, "Kiro CLI v3 hooks config") + if err != nil { + return status, err } - settings, err := readJSONFile(configPath) + contracts, err := configuredKiroContracts( + v2Path, + v2Settings, + v2Exists, + v3Path, + v3Settings, + v3Exists, + ) if err != nil { + return status, err + } + if len(contracts) == 0 { return status, nil } - - hooks, _ := settings["hooks"].(map[string]interface{}) - if hooks != nil { - preConfigured := hasElydoraEntry(hooks["PreToolUse"]) - postConfigured := hasElydoraEntry(hooks["PostToolUse"]) - status.HookConfigured = preConfigured && postConfigured - - // Extract hook script path from the configured command - scriptPath := extractElydoraScriptPath(hooks["PostToolUse"]) - if scriptPath != "" { - if _, err := os.Stat(scriptPath); err == nil { - status.HookScriptExists = true - } - } + status.ConfigPath = contracts[len(contracts)-1].configPath + status.HookConfigured = true + status.HookScriptExists, err = kiroRuntimeScriptsExist(contracts) + if err != nil { + return status, err } - - status.Installed = status.HookConfigured && status.HookScriptExists + status.Installed = status.HookScriptExists return status, nil } - diff --git a/cmd/elydora/plugins/kirocli_contract.go b/cmd/elydora/plugins/kirocli_contract.go new file mode 100644 index 0000000..63fc8e6 --- /dev/null +++ b/cmd/elydora/plugins/kirocli_contract.go @@ -0,0 +1,399 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + kiroAgentKey = "kirocli" + kiroV2AgentName = "elydora-audit" + kiroV2Description = "Kiro CLI with Elydora audit and freeze enforcement" + kiroV3GuardName = "elydora-guard" + kiroV3AuditName = "elydora-audit" +) + +type kiroHookContract struct { + guardCommand string + auditCommand string + configPath string +} + +type kiroConfigMutation struct { + path string + value map[string]any + changed bool + remove bool +} + +func cloneKiroObject(value map[string]any) map[string]any { + clone := make(map[string]any, len(value)) + copyKiroObject(clone, value) + return clone +} + +func copyKiroObject(destination, source map[string]any) { + for key, value := range source { + destination[key] = value + } +} + +func kiroHooksObject(settings map[string]any, label string) (map[string]any, error) { + value, exists := settings["hooks"] + if !exists { + return map[string]any{}, nil + } + hooks, ok := value.(map[string]any) + if !ok || hooks == nil { + return nil, fmt.Errorf(`%s field "hooks" must be an object`, label) + } + return cloneKiroObject(hooks), nil +} + +func kiroHookEntries(hooks map[string]any, event, label string) ([]map[string]any, error) { + value, exists := hooks[event] + if !exists { + return []map[string]any{}, nil + } + entries, ok := value.([]any) + if !ok { + return nil, fmt.Errorf(`%s field "hooks.%s" must be an array of objects`, label, event) + } + objects := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + object, ok := entry.(map[string]any) + if !ok || object == nil { + return nil, fmt.Errorf(`%s field "hooks.%s" must be an array of objects`, label, event) + } + objects = append(objects, object) + } + return objects, nil +} + +func isManagedKiroCommand(command any, scriptName, agentID string) bool { + text, ok := command.(string) + if !ok { + return false + } + normalized := strings.ToLower(text) + if !strings.Contains(normalized, ".elydora") || !strings.Contains(normalized, strings.ToLower(scriptName)) { + return false + } + return agentID == "" || strings.Contains(text, agentID) +} + +func withoutKiroV2Hooks(entries []map[string]any, agentID string) []any { + filtered := make([]any, 0, len(entries)) + for _, hook := range entries { + if isManagedKiroCommand(hook["command"], "guard.js", agentID) || + isManagedKiroCommand(hook["command"], "hook.js", agentID) { + continue + } + filtered = append(filtered, hook) + } + return filtered +} + +func buildKiroV2Hook(runtimePath, scriptPath string) map[string]any { + return map[string]any{ + "matcher": "*", + "command": buildKiroCommand(runtimePath, scriptPath), + "timeout_ms": 5000, + } +} + +func kiroV3Hooks(settings map[string]any) ([]map[string]any, error) { + if value, exists := settings["version"]; exists { + version, ok := value.(string) + if !ok || version != "v1" { + return nil, fmt.Errorf(`Kiro CLI v3 hooks config field "version" must be "v1"`) + } + } + value, exists := settings["hooks"] + if !exists { + return []map[string]any{}, nil + } + hooks, ok := value.([]any) + if !ok { + return nil, fmt.Errorf(`Kiro CLI v3 hooks config field "hooks" must be an array of objects`) + } + objects := make([]map[string]any, 0, len(hooks)) + for _, hook := range hooks { + object, ok := hook.(map[string]any) + if !ok || object == nil { + return nil, fmt.Errorf(`Kiro CLI v3 hooks config field "hooks" must be an array of objects`) + } + objects = append(objects, object) + } + return objects, nil +} + +func kiroV3ActionCommand(hook map[string]any) any { + action, ok := hook["action"].(map[string]any) + if !ok { + return nil + } + return action["command"] +} + +func isManagedKiroV3Hook(hook map[string]any, agentID string) bool { + switch hook["name"] { + case kiroV3GuardName: + return isManagedKiroCommand(kiroV3ActionCommand(hook), "guard.js", agentID) + case kiroV3AuditName: + return isManagedKiroCommand(kiroV3ActionCommand(hook), "hook.js", agentID) + default: + return false + } +} + +func buildKiroV3Hook(name, description, trigger, runtimePath, scriptPath string) map[string]any { + return map[string]any{ + "name": name, + "description": description, + "trigger": trigger, + "matcher": ".*", + "action": map[string]any{ + "type": "command", + "command": buildKiroCommand(runtimePath, scriptPath), + }, + "timeout": 5, + "enabled": true, + } +} + +func prepareKiroV2Uninstall( + path string, + settings map[string]any, + exists bool, + agentID string, +) (kiroConfigMutation, error) { + mutation := kiroConfigMutation{path: path} + if !exists { + return mutation, nil + } + hooks, err := kiroHooksObject(settings, "Kiro CLI v2 agent config") + if err != nil { + return mutation, err + } + pre, err := kiroHookEntries(hooks, "preToolUse", "Kiro CLI v2 agent config") + if err != nil { + return mutation, err + } + post, err := kiroHookEntries(hooks, "postToolUse", "Kiro CLI v2 agent config") + if err != nil { + return mutation, err + } + filteredPre := withoutKiroV2Hooks(pre, agentID) + filteredPost := withoutKiroV2Hooks(post, agentID) + if len(filteredPre) == len(pre) && len(filteredPost) == len(post) { + return mutation, nil + } + hooks["preToolUse"] = filteredPre + hooks["postToolUse"] = filteredPost + next := cloneKiroObject(settings) + next["hooks"] = hooks + mutation.value = next + mutation.changed = true + mutation.remove = isKiroV2Owned(settings, hooks) + return mutation, nil +} + +func isKiroV2Owned(settings, hooks map[string]any) bool { + allowedConfigKeys := map[string]bool{ + "name": true, "description": true, "tools": true, "includeMcpJson": true, "hooks": true, + } + for key := range settings { + if !allowedConfigKeys[key] { + return false + } + } + tools, ok := settings["tools"].([]any) + if settings["name"] != kiroV2AgentName || settings["description"] != kiroV2Description || + !ok || len(tools) != 1 || tools[0] != "*" || settings["includeMcpJson"] != true { + return false + } + for event, value := range hooks { + if event != "preToolUse" && event != "postToolUse" { + return false + } + entries, ok := value.([]any) + if !ok || len(entries) != 0 { + return false + } + } + return true +} + +func prepareKiroV3Uninstall( + path string, + settings map[string]any, + exists bool, + agentID string, +) (kiroConfigMutation, error) { + mutation := kiroConfigMutation{path: path} + if !exists { + return mutation, nil + } + hooks, err := kiroV3Hooks(settings) + if err != nil { + return mutation, err + } + filtered := make([]any, 0, len(hooks)) + for _, hook := range hooks { + if !isManagedKiroV3Hook(hook, agentID) { + filtered = append(filtered, hook) + } + } + if len(filtered) == len(hooks) { + return mutation, nil + } + next := cloneKiroObject(settings) + next["hooks"] = filtered + owned := true + for key := range settings { + if key != "version" && key != "hooks" { + owned = false + } + } + mutation.value = next + mutation.changed = true + mutation.remove = owned && len(filtered) == 0 + return mutation, nil +} + +func applyKiroMutation(mutation kiroConfigMutation) error { + if !mutation.changed { + return nil + } + if mutation.remove { + return removeKiroFile(mutation.path, "Kiro CLI config") + } + return writeKiroObjectAtomic(mutation.path, mutation.value) +} + +func configuredKiroContracts( + v2Path string, + v2Settings map[string]any, + v2Exists bool, + v3Path string, + v3Settings map[string]any, + v3Exists bool, +) ([]kiroHookContract, error) { + contracts := make([]kiroHookContract, 0, 2) + if v2Exists { + hooks, err := kiroHooksObject(v2Settings, "Kiro CLI v2 agent config") + if err != nil { + return nil, err + } + pre, err := kiroHookEntries(hooks, "preToolUse", "Kiro CLI v2 agent config") + if err != nil { + return nil, err + } + post, err := kiroHookEntries(hooks, "postToolUse", "Kiro CLI v2 agent config") + if err != nil { + return nil, err + } + guard := findKiroV2Command(pre, "guard.js") + audit := findKiroV2Command(post, "hook.js") + if guard != "" && audit != "" { + contracts = append(contracts, kiroHookContract{guard, audit, v2Path}) + } + } + if v3Exists { + hooks, err := kiroV3Hooks(v3Settings) + if err != nil { + return nil, err + } + guard := findKiroV3Command(hooks, kiroV3GuardName, "guard.js") + audit := findKiroV3Command(hooks, kiroV3AuditName, "hook.js") + if guard != "" && audit != "" { + contracts = append(contracts, kiroHookContract{guard, audit, v3Path}) + } + } + return contracts, nil +} + +func findKiroV2Command(entries []map[string]any, scriptName string) string { + for _, hook := range entries { + if isManagedKiroCommand(hook["command"], scriptName, "") { + return hook["command"].(string) + } + } + return "" +} + +func findKiroV3Command(entries []map[string]any, name, scriptName string) string { + for _, hook := range entries { + command := kiroV3ActionCommand(hook) + if hook["name"] == name && isManagedKiroCommand(command, scriptName, "") { + return command.(string) + } + } + return "" +} + +func regularFileExists(path, label string) (bool, error) { + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("read %s at %s: %w", label, path, err) + } + return info.Mode().IsRegular(), nil +} + +func kiroRuntimeScriptsExist(contracts []kiroHookContract) (bool, error) { + home, err := os.UserHomeDir() + if err != nil { + return false, fmt.Errorf("resolve home directory: %w", err) + } + root := filepath.Join(home, ".elydora") + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, fmt.Errorf("read Elydora runtime directory at %s: %w", root, err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + agentDir := filepath.Join(root, entry.Name()) + guardPath := filepath.Join(agentDir, "guard.js") + hookPath := filepath.Join(agentDir, "hook.js") + referenced := false + for _, contract := range contracts { + if strings.Contains(contract.guardCommand, guardPath) && + strings.Contains(contract.auditCommand, hookPath) { + referenced = true + break + } + } + if !referenced { + continue + } + configPath := filepath.Join(agentDir, "config.json") + config, exists, err := readKiroObject(configPath, "Elydora runtime config") + if err != nil { + return false, err + } + if !exists || config["agent_name"] != kiroAgentKey { + continue + } + guardExists, err := regularFileExists(guardPath, "Elydora guard runtime") + if err != nil { + return false, err + } + hookExists, err := regularFileExists(hookPath, "Elydora audit runtime") + if err != nil { + return false, err + } + return guardExists && hookExists, nil + } + return false, nil +} diff --git a/cmd/elydora/plugins/kirocli_io.go b/cmd/elydora/plugins/kirocli_io.go new file mode 100644 index 0000000..c4294cb --- /dev/null +++ b/cmd/elydora/plugins/kirocli_io.go @@ -0,0 +1,142 @@ +package plugins + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +func kiroConfigPaths() (string, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".kiro", "agents", kiroV2AgentName+".json"), + filepath.Join(home, ".kiro", "hooks", "elydora-audit.json"), nil +} + +func readKiroObject(path, label string) (map[string]any, bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return map[string]any{}, false, nil + } + return nil, false, fmt.Errorf("read %s at %s: %w", label, path, err) + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, true, fmt.Errorf("parse %s at %s: %w", label, path, err) + } + object, ok := value.(map[string]any) + if !ok || object == nil { + return nil, true, fmt.Errorf("%s at %s must contain a JSON object", label, path) + } + return object, true, nil +} + +func writeKiroObjectAtomic(path string, value map[string]any) error { + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("marshal %s: %w", path, err) + } + encoded = append(encoded, '\n') + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0700); err != nil { + return fmt.Errorf("create directory %s: %w", directory, err) + } + temporary, err := os.CreateTemp(directory, "."+filepath.Base(path)+".*.tmp") + if err != nil { + return fmt.Errorf("create temporary file for %s: %w", path, err) + } + temporaryPath := temporary.Name() + cleanup := func(cause error) error { + closeErr := temporary.Close() + removeErr := os.Remove(temporaryPath) + if os.IsNotExist(removeErr) { + removeErr = nil + } + return errors.Join(cause, closeErr, removeErr) + } + if err := temporary.Chmod(0600); err != nil { + return cleanup(fmt.Errorf("set permissions for %s: %w", temporaryPath, err)) + } + if _, err := temporary.Write(encoded); err != nil { + return cleanup(fmt.Errorf("write temporary file for %s: %w", path, err)) + } + if err := temporary.Sync(); err != nil { + return cleanup(fmt.Errorf("sync temporary file for %s: %w", path, err)) + } + if err := temporary.Close(); err != nil { + return cleanup(fmt.Errorf("close temporary file for %s: %w", path, err)) + } + if err := os.Rename(temporaryPath, path); err != nil { + removeErr := os.Remove(temporaryPath) + if os.IsNotExist(removeErr) { + removeErr = nil + } + return errors.Join(fmt.Errorf("replace %s: %w", path, err), removeErr) + } + return nil +} + +func removeKiroFile(path, label string) error { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s at %s: %w", label, path, err) + } + return nil +} + +func resolveNodeRuntime() (string, error) { + nodePath, err := exec.LookPath("node") + if err != nil { + return "", fmt.Errorf("resolve Node.js runtime: %w", err) + } + absolute, err := filepath.Abs(nodePath) + if err != nil { + return "", fmt.Errorf("resolve Node.js runtime path: %w", err) + } + return absolute, nil +} + +func buildKiroCommand(runtimePath, scriptPath string) string { + if runtime.GOOS == "windows" { + return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) + } + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) +} + +func quotePOSIXArgument(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func quoteWindowsArgument(value string) string { + if value != "" && !strings.ContainsAny(value, " \t\n\v\"") { + return value + } + var result strings.Builder + result.WriteByte('"') + backslashes := 0 + for _, character := range value { + if character == '\\' { + backslashes++ + continue + } + if character == '"' { + result.WriteString(strings.Repeat("\\", backslashes*2+1)) + result.WriteRune(character) + backslashes = 0 + continue + } + result.WriteString(strings.Repeat("\\", backslashes)) + backslashes = 0 + result.WriteRune(character) + } + result.WriteString(strings.Repeat("\\", backslashes*2)) + result.WriteByte('"') + return result.String() +} diff --git a/cmd/elydora/plugins/kirocli_test.go b/cmd/elydora/plugins/kirocli_test.go new file mode 100644 index 0000000..7b6fc25 --- /dev/null +++ b/cmd/elydora/plugins/kirocli_test.go @@ -0,0 +1,436 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +const kiroTestAgentID = "agent-1" + +type kiroFixture struct { + plugin *KiroCliPlugin + config InstallConfig + homeDir string + guardPath string + hookPath string + runtimeConfig string + v2Path string + v3Path string +} + +func prepareKiroFixture(t *testing.T, v2Raw, v3Raw string) *kiroFixture { + t.Helper() + homeDir := filepath.Join(t.TempDir(), "home with spaces") + agentDir := filepath.Join(homeDir, ".elydora", kiroTestAgentID) + guardPath := filepath.Join(agentDir, "guard.js") + v2Path := filepath.Join(homeDir, ".kiro", "agents", "elydora-audit.json") + v3Path := filepath.Join(homeDir, ".kiro", "hooks", "elydora-audit.json") + if err := os.MkdirAll(agentDir, 0755); err != nil { + t.Fatalf("create agent directory: %v", err) + } + if err := os.WriteFile( + guardPath, + []byte("process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n"), + 0600, + ); err != nil { + t.Fatalf("write guard: %v", err) + } + writeOptionalKiroConfig(t, v2Path, v2Raw) + writeOptionalKiroConfig(t, v3Path, v3Raw) + t.Setenv("HOME", homeDir) + t.Setenv("USERPROFILE", homeDir) + + return &kiroFixture{ + plugin: &KiroCliPlugin{}, + homeDir: homeDir, + config: InstallConfig{ + AgentName: "kirocli", + OrgID: "org-1", + AgentID: kiroTestAgentID, + PrivateKey: "test-key", + KID: "kid-1", + BaseURL: "https://api.elydora.test", + GuardScriptPath: guardPath, + }, + guardPath: guardPath, + hookPath: filepath.Join(agentDir, "hook.js"), + runtimeConfig: filepath.Join(agentDir, "config.json"), + v2Path: v2Path, + v3Path: v3Path, + } +} + +func writeOptionalKiroConfig(t *testing.T, path, raw string) { + t.Helper() + if raw == "" { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatalf("create config directory: %v", err) + } + if err := os.WriteFile(path, []byte(raw), 0600); err != nil { + t.Fatalf("write config: %v", err) + } +} + +func readKiroTestObject(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 requireObject(t *testing.T, value any) map[string]any { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("value = %#v, want object", value) + } + return object +} + +func requireArray(t *testing.T, value any) []any { + t.Helper() + array, ok := value.([]any) + if !ok { + t.Fatalf("value = %#v, want array", value) + } + return array +} + +func findKiroV3Hook(t *testing.T, config map[string]any, name string) map[string]any { + t.Helper() + for _, value := range requireArray(t, config["hooks"]) { + hook := requireObject(t, value) + if hook["name"] == name { + return hook + } + } + t.Fatalf("hook %q not found", name) + return nil +} + +func kiroHookCommand(t *testing.T, hook map[string]any) string { + t.Helper() + action := requireObject(t, hook["action"]) + command, ok := action["command"].(string) + if !ok { + t.Fatalf("hook command = %#v, want string", action["command"]) + } + return command +} + +func marshalKiroPayload(t *testing.T, payload map[string]any) []byte { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal Kiro hook payload: %v", err) + } + return encoded +} + +func runKiroCommand(t *testing.T, command, homeDir string, payload []byte) (int, string) { + t.Helper() + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + commandFile := filepath.Join(t.TempDir(), "run-kiro-hook.cmd") + if err := os.WriteFile(commandFile, []byte("@echo off\r\n"+command+"\r\n"), 0600); err != nil { + t.Fatalf("write hook command file: %v", err) + } + cmd = exec.Command("cmd.exe", "/d", "/c", commandFile) + } else { + cmd = exec.Command("sh", "-c", command) + } + cmd.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) + cmd.Stdin = bytes.NewReader(payload) + var stderr bytes.Buffer + cmd.Stderr = &stderr + err := cmd.Run() + if err == nil { + return 0, stderr.String() + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return exitError.ExitCode(), stderr.String() + } + t.Fatalf("run hook command: %v", err) + return -1, stderr.String() +} + +func TestKiroCLIRegistryPointsAtV3GlobalHookContract(t *testing.T) { + entry := SupportedAgents["kirocli"] + if entry.Name != "Kiro CLI" || entry.ConfigDir != "~/.kiro/hooks" || entry.ConfigFile != "elydora-audit.json" { + t.Fatalf("Kiro CLI registry entry = %#v", entry) + } +} + +func TestKiroCLIInstallPreservesHooksAndWritesIdempotentV2V3Contracts(t *testing.T) { + fixture := prepareKiroFixture(t, `{ + "description": "User Kiro agent", + "tools": ["read"], + "hooks": { + "agentSpawn": [{"command": "existing-spawn"}], + "preToolUse": [{"matcher": "read", "command": "existing-v2"}] + } +}`, `{ + "version": "v1", + "hooks": [{ + "name": "existing-v3", + "trigger": "SessionStart", + "action": {"type": "command", "command": "existing-command"} + }] +}`) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro CLI hooks: %v", err) + } + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repeat Kiro CLI install: %v", err) + } + + v2 := readKiroTestObject(t, fixture.v2Path) + if v2["description"] != "User Kiro agent" || !reflect.DeepEqual(v2["tools"], []any{"read"}) { + t.Fatalf("v2 user fields changed: %#v", v2) + } + v2Hooks := requireObject(t, v2["hooks"]) + v2Pre := requireArray(t, v2Hooks["preToolUse"]) + if len(v2Pre) != 2 || len(requireArray(t, v2Hooks["postToolUse"])) != 1 { + t.Fatalf("unexpected v2 hooks: %#v", v2Hooks) + } + v2Guard := requireObject(t, v2Pre[1]) + if v2Guard["matcher"] != "*" || v2Guard["timeout_ms"] != float64(5000) { + t.Fatalf("unexpected v2 guard: %#v", v2Guard) + } + if _, ok := v2Guard["command"].(string); !ok { + t.Fatalf("v2 guard command = %#v, want string", v2Guard["command"]) + } + if !reflect.DeepEqual(v2Hooks["agentSpawn"], []any{map[string]any{"command": "existing-spawn"}}) { + t.Fatalf("v2 unrelated hook changed: %#v", v2Hooks["agentSpawn"]) + } + + v3 := readKiroTestObject(t, fixture.v3Path) + if v3["version"] != "v1" || len(requireArray(t, v3["hooks"])) != 3 { + t.Fatalf("unexpected v3 config: %#v", v3) + } + guard := findKiroV3Hook(t, v3, "elydora-guard") + if guard["trigger"] != "PreToolUse" || guard["matcher"] != ".*" || guard["timeout"] != float64(5) || guard["enabled"] != true { + t.Fatalf("unexpected v3 guard: %#v", guard) + } + if requireObject(t, guard["action"])["type"] != "command" { + t.Fatalf("unexpected v3 guard action: %#v", guard["action"]) + } + if findKiroV3Hook(t, v3, "elydora-audit")["trigger"] != "PostToolUse" { + t.Fatalf("unexpected v3 audit hook: %#v", v3) + } +} + +func TestKiroCLICommandsBlockFrozenAgentAndForwardOfficialPayload(t *testing.T) { + fixture := prepareKiroFixture(t, "", "") + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro CLI hooks: %v", err) + } + capturePath := filepath.Join(t.TempDir(), "captured-event.json") + captureJSON, err := json.Marshal(capturePath) + if err != nil { + t.Fatalf("marshal capture path: %v", err) + } + captureScript := "const fs = require('node:fs'); const chunks = []; " + + "process.stdin.on('data', chunk => chunks.push(chunk)); " + + "process.stdin.on('end', () => fs.writeFileSync(" + string(captureJSON) + ", Buffer.concat(chunks)));\n" + if err := os.WriteFile(fixture.hookPath, []byte(captureScript), 0600); err != nil { + t.Fatalf("write capture hook: %v", err) + } + + v3 := readKiroTestObject(t, fixture.v3Path) + payload := map[string]any{ + "hook_event_name": "PreToolUse", + "cwd": fixture.homeDir, + "session_id": "session-1", + "tool_name": "execute_bash", + "tool_input": map[string]any{"command": "echo test"}, + } + encoded := marshalKiroPayload(t, payload) + exitCode, stderr := runKiroCommand(t, kiroHookCommand(t, findKiroV3Hook(t, v3, "elydora-guard")), fixture.homeDir, encoded) + if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") { + t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr) + } + + payload["hook_event_name"] = "PostToolUse" + payload["tool_response"] = map[string]any{"success": true, "result": "test"} + encoded = marshalKiroPayload(t, payload) + exitCode, stderr = runKiroCommand(t, kiroHookCommand(t, findKiroV3Hook(t, v3, "elydora-audit")), fixture.homeDir, encoded) + if exitCode != 0 { + t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr) + } + var captured map[string]any + if raw, err := os.ReadFile(capturePath); err != nil { + t.Fatalf("read captured event: %v", err) + } else if err := json.Unmarshal(raw, &captured); err != nil { + t.Fatalf("decode captured event: %v", err) + } + if !reflect.DeepEqual(captured, payload) { + t.Fatalf("captured event = %#v, want %#v", captured, payload) + } +} + +func TestKiroCLIStatusAcceptsEitherContractAndRequiresBothRuntimes(t *testing.T) { + fixture := prepareKiroFixture(t, "", "") + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro CLI hooks: %v", err) + } + assertKiroStatus(t, fixture.plugin, true, fixture.v3Path) + + if err := os.Remove(fixture.v3Path); err != nil { + t.Fatalf("remove v3 config: %v", err) + } + assertKiroStatus(t, fixture.plugin, true, fixture.v2Path) + + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("reinstall Kiro CLI hooks: %v", err) + } + if err := os.Remove(fixture.v2Path); err != nil { + t.Fatalf("remove v2 config: %v", err) + } + assertKiroStatus(t, fixture.plugin, true, fixture.v3Path) + + if err := os.Remove(fixture.guardPath); err != nil { + t.Fatalf("remove guard: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil { + t.Fatalf("read degraded status: %v", err) + } + if status.Installed || !status.HookConfigured || status.HookScriptExists { + t.Fatalf("unexpected degraded status: %#v", status) + } +} + +func assertKiroStatus(t *testing.T, plugin *KiroCliPlugin, installed bool, configPath string) { + t.Helper() + status, err := plugin.Status() + if err != nil { + t.Fatalf("read Kiro CLI status: %v", err) + } + if status.Installed != installed || status.ConfigPath != configPath { + t.Fatalf("status = %#v, want installed=%v config=%q", status, installed, configPath) + } +} + +func TestKiroCLIStatusSurfacesMalformedReferencedRuntimeMetadata(t *testing.T) { + fixture := prepareKiroFixture(t, "", "") + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro CLI hooks: %v", err) + } + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + if _, err := fixture.plugin.Status(); err == nil || !strings.Contains(err.Error(), "parse Elydora runtime config") { + t.Fatalf("status error = %v", err) + } +} + +func TestKiroCLIUninstallPreservesUnrelatedHooks(t *testing.T) { + fixture := prepareKiroFixture(t, `{"hooks":{"preToolUse":[{"matcher":"read","command":"existing-v2"}]}}`, `{ + "version":"v1", + "hooks":[{"name":"existing-v3","trigger":"SessionStart","action":{"type":"command","command":"existing-command"}}] +}`) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro CLI hooks: %v", err) + } + if err := fixture.plugin.Uninstall(kiroTestAgentID); err != nil { + t.Fatalf("uninstall Kiro CLI hooks: %v", err) + } + + v2Hooks := requireObject(t, readKiroTestObject(t, fixture.v2Path)["hooks"]) + if !reflect.DeepEqual(v2Hooks["preToolUse"], []any{map[string]any{"matcher": "read", "command": "existing-v2"}}) { + t.Fatalf("unexpected v2 hooks after uninstall: %#v", v2Hooks) + } + if len(requireArray(t, v2Hooks["postToolUse"])) != 0 { + t.Fatalf("managed v2 audit hook remains: %#v", v2Hooks) + } + v3Hooks := requireArray(t, readKiroTestObject(t, fixture.v3Path)["hooks"]) + if len(v3Hooks) != 1 || requireObject(t, v3Hooks[0])["name"] != "existing-v3" { + t.Fatalf("unexpected v3 hooks after uninstall: %#v", v3Hooks) + } +} + +func TestKiroCLIUninstallRemovesElydoraOwnedConfigs(t *testing.T) { + fixture := prepareKiroFixture(t, "", "") + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kiro CLI hooks: %v", err) + } + if err := fixture.plugin.Uninstall(kiroTestAgentID); err != nil { + t.Fatalf("uninstall Kiro CLI hooks: %v", err) + } + for _, path := range []string{fixture.v2Path, fixture.v3Path} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("owned config still exists at %s: %v", path, err) + } + } +} + +func TestKiroCLIInstallPreservesMalformedConfigsBeforeRuntimeWrite(t *testing.T) { + for _, testCase := range []struct { + name string + v2Raw string + v3Raw string + badPath func(*kiroFixture) string + }{ + {name: "v2", v2Raw: "{ malformed", badPath: func(f *kiroFixture) string { return f.v2Path }}, + {name: "v3", v3Raw: "{ malformed", badPath: func(f *kiroFixture) string { return f.v3Path }}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKiroFixture(t, testCase.v2Raw, testCase.v3Raw) + if err := fixture.plugin.Install(fixture.config); err == nil || !strings.Contains(err.Error(), "parse Kiro CLI "+testCase.name) { + t.Fatalf("install error = %v", err) + } + raw, err := os.ReadFile(testCase.badPath(fixture)) + if err != nil || string(raw) != "{ malformed" { + t.Fatalf("malformed config changed: %q, %v", raw, err) + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("runtime write occurred at %s: %v", path, err) + } + } + }) + } +} + +func TestKiroCLIInstallRejectsInvalidContractShapesBeforeRuntimeWrite(t *testing.T) { + for _, testCase := range []struct { + name string + v2Raw string + v3Raw string + want string + }{ + {name: "v2-null-hooks", v2Raw: `{"hooks":null}`, want: `field "hooks" must be an object`}, + {name: "v3-null-version", v3Raw: `{"version":null,"hooks":[]}`, want: `field "version" must be "v1"`}, + {name: "v3-null-hooks", v3Raw: `{"version":"v1","hooks":null}`, want: `field "hooks" must be an array`}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKiroFixture(t, testCase.v2Raw, testCase.v3Raw) + if err := fixture.plugin.Install(fixture.config); err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v, want %q", err, testCase.want) + } + for _, path := range []string{fixture.hookPath, fixture.runtimeConfig} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("runtime write occurred at %s: %v", path, err) + } + } + }) + } +} diff --git a/cmd/elydora/plugins/registry.go b/cmd/elydora/plugins/registry.go index 160bb58..7f38f5d 100644 --- a/cmd/elydora/plugins/registry.go +++ b/cmd/elydora/plugins/registry.go @@ -12,7 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{ "claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}, "gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.json"}, - "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro", ConfigFile: "settings.json"}, + "kirocli": {Name: "Kiro CLI", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.json"}, "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"}, "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},