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 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 cloneJSONObject(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 := cloneJSONObject(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 := cloneJSONObject(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 removeHookFile(mutation.path, "Kiro CLI config") } return writeHookJSONObjectAtomic(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 := readHookJSONObject(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 }