feat: support Kiro CLI v2 and v3 hooks

This commit is contained in:
ZacharyZhang-NY committed 2026-07-19 04:58:54 -04:00
1 parent 598b0bd2bb
commit 758d129dbf
7 files changed
+1174 -170

No files matched your search

+51
View File
@@ -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 <Provider> -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.
+2
View File
@@ -74,6 +74,8 @@ elydora install \
--kid agent-456-key-v1 --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 ### Commands
| Command | Description | | Command | Description |
+143 -169
View File
@@ -1,24 +1,46 @@
package plugins package plugins
import ( import "fmt"
"fmt"
"os"
"path/filepath"
)
// KiroCliPlugin manages the Elydora audit hook for Kiro CLI. // KiroCliPlugin manages the stable v2 custom-agent and early-access v3 hook contracts.
// It merges PreToolUse/PostToolUse hooks into ~/.kiro/settings.json.
type KiroCliPlugin struct{} 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 { 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) scriptPath, err := hookScriptPath(config.AgentID)
if err != nil { if err != nil {
return err return err
@@ -26,199 +48,151 @@ func (p *KiroCliPlugin) Install(config InstallConfig) error {
if config.HookScript != "" { if config.HookScript != "" {
scriptPath = 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 { if err := GenerateHookScript(scriptPath, config); err != nil {
return fmt.Errorf("generate hook script: %w", err) return fmt.Errorf("generate hook script: %w", err)
} }
if err := writeKiroObjectAtomic(v2Path, nextV2); err != nil {
guardPath := config.GuardScriptPath
if guardPath == "" {
guardPath, err = guardScriptPath(config.AgentID)
if err != nil {
return err return err
} }
} if err := writeKiroObjectAtomic(v3Path, nextV3); err != nil {
configPath, err := p.configPath()
if err != nil {
return err return err
} }
settings, err := readJSONFile(configPath) fmt.Println(`Kiro CLI v2: start with "kiro-cli --agent elydora-audit".`)
if err != nil { fmt.Println(`Kiro CLI v3: start with "kiro-cli --v3"; global hooks load automatically.`)
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)
return nil return nil
} }
func (p *KiroCliPlugin) Uninstall(agentID string) error { 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 { if err != nil {
return err return err
} }
settings, err := readJSONFile(configPath) v2Mutation, err := prepareKiroV2Uninstall(v2Path, v2Settings, v2Exists, agentID)
if err != nil { if err != nil {
return err return err
} }
v3Mutation, err := prepareKiroV3Uninstall(v3Path, v3Settings, v3Exists, agentID)
hooks, _ := settings["hooks"].(map[string]interface{}) if err != nil {
if hooks == nil { return err
fmt.Println("No Kiro CLI hooks found.")
return nil
} }
if err := applyKiroMutation(v2Mutation); err != nil {
// Remove PreToolUse Elydora entries return err
preToolUse, _ := hooks["PreToolUse"].([]interface{})
var preFiltered []interface{}
for _, entry := range preToolUse {
if m, ok := entry.(map[string]interface{}); ok {
if isElydoraHookEntry(m) {
continue
} }
} if err := applyKiroMutation(v3Mutation); err != nil {
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
} }
if agentID != "" { fmt.Println("Uninstalled Elydora hooks from Kiro CLI.")
scriptPath, _ := hookScriptPath(agentID)
if scriptPath != "" {
os.Remove(scriptPath)
}
gPath, _ := guardScriptPath(agentID)
if gPath != "" {
os.Remove(gPath)
}
}
fmt.Println("Uninstalled Elydora hook for Kiro CLI.")
return nil return nil
} }
func (p *KiroCliPlugin) Status() (PluginStatus, error) { func (p *KiroCliPlugin) Status() (PluginStatus, error) {
configPath, err := p.configPath() v2Path, v3Path, err := kiroConfigPaths()
if err != nil { if err != nil {
return PluginStatus{}, err return PluginStatus{}, err
} }
status := PluginStatus{ status := PluginStatus{
AgentName: "kirocli", AgentName: kiroAgentKey,
DisplayName: "Kiro CLI", 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 { if err != nil {
return status, err
}
if len(contracts) == 0 {
return status, nil return status, nil
} }
status.ConfigPath = contracts[len(contracts)-1].configPath
hooks, _ := settings["hooks"].(map[string]interface{}) status.HookConfigured = true
if hooks != nil { status.HookScriptExists, err = kiroRuntimeScriptsExist(contracts)
preConfigured := hasElydoraEntry(hooks["PreToolUse"]) if err != nil {
postConfigured := hasElydoraEntry(hooks["PostToolUse"]) return status, err
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.Installed = status.HookScriptExists
}
status.Installed = status.HookConfigured && status.HookScriptExists
return status, nil return status, nil
} }
+399
View File
@@ -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
}
+142
View File
@@ -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()
}
+436
View File
@@ -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)
}
}
})
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ var SupportedAgents = map[string]AgentRegistryEntry{
"claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"}, "claudecode": {Name: "Claude Code", ConfigDir: "~/.claude", ConfigFile: "settings.json"},
"cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"}, "cursor": {Name: "Cursor", ConfigDir: "~/.cursor", ConfigFile: "hooks.json"},
"gemini": {Name: "Gemini CLI", ConfigDir: "~/.gemini", ConfigFile: "settings.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"}, "kiroide": {Name: "Kiro IDE", ConfigDir: "~/.kiro/hooks", ConfigFile: "elydora-audit.kiro.hook"},
"opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"}, "opencode": {Name: "OpenCode", ConfigDir: "~/.config/opencode/plugins", ConfigFile: "elydora-audit.mjs"},
"copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"}, "copilot": {Name: "Copilot CLI", ConfigDir: ".github/hooks", ConfigFile: "hooks.json"},