Split the monolithic transaction into focused modules: append-only crash-recoverable journal under ~/.elydora/transactions with owner tokens and per-transaction namespaces, staged atomic publication (stage .pending, fsync, rename, fsync), platform-specific atomic replacement (MoveFileEx, renameat2, renamex_np), private-directory hardening per OS, lock-guarded recovery, and workspace cleanup that removes only transaction-created empty directories. Add the Kiro IDE 1.0 plugin: strict v1 hook schema with one enabled PreToolUse guard and PostToolUse audit pair bound to the active workspace, agent-to-workspace identity binding in runtime config, and exact migration of the legacy elydora-audit.kiro.hook contract. Gates: go test ./... and -race pass, go vet clean, govulncheck reports zero reachable vulnerabilities, git diff --check clean.
461 lines
12 KiB
Go
461 lines
12 KiB
Go
package plugins
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"reflect"
|
|
)
|
|
|
|
const (
|
|
kiroIdeAgentKey = "kiroide"
|
|
kiroIdeConfigFile = "elydora-audit.json"
|
|
kiroIdeLegacyFile = "elydora-audit.kiro.hook"
|
|
kiroIdeGuardScript = "guard.js"
|
|
kiroIdeAuditScript = "hook.js"
|
|
kiroIdeGuardName = "elydora-guard"
|
|
kiroIdeAuditName = "elydora-audit"
|
|
kiroIdeHookTimeout = float64(10)
|
|
kiroIdeGuardDescription = "Block tool use when the Elydora agent is frozen"
|
|
kiroIdeAuditDescription = "Record tool use in the Elydora audit trail"
|
|
)
|
|
|
|
var kiroIdeTriggers = stringSet(
|
|
"SessionStart",
|
|
"Stop",
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"PreTaskExec",
|
|
"PostTaskExec",
|
|
"UserPromptSubmit",
|
|
"PostFileCreate",
|
|
"PostFileSave",
|
|
"PostFileDelete",
|
|
)
|
|
|
|
type kiroIdeDocument struct {
|
|
exists bool
|
|
filePath string
|
|
root map[string]any
|
|
hooks []map[string]any
|
|
snapshot *managedFileSnapshot
|
|
}
|
|
|
|
type kiroIdeLegacyDocument struct {
|
|
filePath string
|
|
snapshot *managedFileSnapshot
|
|
contract *kiroIdeRuntimeContract
|
|
}
|
|
|
|
type kiroIdeRenderedDocument struct {
|
|
document *kiroIdeDocument
|
|
changed bool
|
|
next []byte
|
|
remove bool
|
|
}
|
|
|
|
type kiroIdeRuntimeContract struct {
|
|
agentID string
|
|
guardPath string
|
|
auditPath string
|
|
}
|
|
|
|
type kiroIdeHookSpecification struct {
|
|
description string
|
|
trigger string
|
|
scriptName string
|
|
}
|
|
|
|
func kiroIdeSpecification(name any) (kiroIdeHookSpecification, bool) {
|
|
switch name {
|
|
case kiroIdeGuardName:
|
|
return kiroIdeHookSpecification{
|
|
kiroIdeGuardDescription, "PreToolUse", kiroIdeGuardScript,
|
|
}, true
|
|
case kiroIdeAuditName:
|
|
return kiroIdeHookSpecification{
|
|
kiroIdeAuditDescription, "PostToolUse", kiroIdeAuditScript,
|
|
}, true
|
|
default:
|
|
return kiroIdeHookSpecification{}, false
|
|
}
|
|
}
|
|
|
|
func cloneKiroIdeObject(value map[string]any) map[string]any {
|
|
clone := make(map[string]any, len(value))
|
|
for key, item := range value {
|
|
clone[key] = item
|
|
}
|
|
return clone
|
|
}
|
|
|
|
func validateKiroIdeAction(value any, label string) error {
|
|
action, ok := value.(map[string]any)
|
|
if !ok || action == nil {
|
|
return fmt.Errorf("%s action must be an object", label)
|
|
}
|
|
switch action["type"] {
|
|
case "command":
|
|
command, ok := action["command"].(string)
|
|
if !ok || command == "" {
|
|
return fmt.Errorf("%s command action requires a non-empty command", label)
|
|
}
|
|
case "agent":
|
|
prompt, ok := action["prompt"].(string)
|
|
if !ok || prompt == "" {
|
|
return fmt.Errorf("%s agent action requires a non-empty prompt", label)
|
|
}
|
|
default:
|
|
return fmt.Errorf(
|
|
`%s action has unsupported type %q`,
|
|
label,
|
|
fmt.Sprint(action["type"]),
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateKiroIdeHook(value any, index int, label string) (map[string]any, error) {
|
|
hook, ok := value.(map[string]any)
|
|
if !ok || hook == nil {
|
|
return nil, fmt.Errorf("%s hooks[%d] must be an object", label, index)
|
|
}
|
|
hookLabel := fmt.Sprintf("%s hooks[%d]", label, index)
|
|
name, ok := hook["name"].(string)
|
|
if !ok || name == "" {
|
|
return nil, fmt.Errorf("%s requires a name", hookLabel)
|
|
}
|
|
trigger, ok := hook["trigger"].(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("%s requires a trigger", hookLabel)
|
|
}
|
|
if _, supported := kiroIdeTriggers[trigger]; !supported {
|
|
return nil, fmt.Errorf("%s has unsupported trigger %q", hookLabel, trigger)
|
|
}
|
|
if value, exists := hook["description"]; exists {
|
|
if _, ok := value.(string); !ok {
|
|
return nil, fmt.Errorf("%s description must be a string", hookLabel)
|
|
}
|
|
}
|
|
if value, exists := hook["matcher"]; exists {
|
|
if _, ok := value.(string); !ok {
|
|
return nil, fmt.Errorf("%s matcher must be a string", hookLabel)
|
|
}
|
|
}
|
|
if value, exists := hook["timeout"]; exists {
|
|
timeout, ok := value.(float64)
|
|
if !ok || math.IsNaN(timeout) || math.IsInf(timeout, 0) ||
|
|
timeout < 0 || timeout > 9007199254740991 || math.Trunc(timeout) != timeout {
|
|
return nil, fmt.Errorf("%s timeout must be a non-negative integer", hookLabel)
|
|
}
|
|
}
|
|
if value, exists := hook["enabled"]; exists {
|
|
if _, ok := value.(bool); !ok {
|
|
return nil, fmt.Errorf("%s enabled must be a boolean", hookLabel)
|
|
}
|
|
}
|
|
if err := validateKiroIdeAction(hook["action"], hookLabel); err != nil {
|
|
return nil, err
|
|
}
|
|
return hook, nil
|
|
}
|
|
|
|
func parseKiroIdeDocument(
|
|
filePath string,
|
|
snapshot *managedFileSnapshot,
|
|
) (*kiroIdeDocument, error) {
|
|
if snapshot == nil {
|
|
return &kiroIdeDocument{
|
|
filePath: filePath,
|
|
root: map[string]any{},
|
|
hooks: []map[string]any{},
|
|
}, nil
|
|
}
|
|
label := fmt.Sprintf("Kiro IDE hooks at %s", filePath)
|
|
root, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if root["version"] != "v1" {
|
|
return nil, fmt.Errorf(`Kiro IDE hooks version must be "v1": %s`, filePath)
|
|
}
|
|
values, ok := root["hooks"].([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf(`Kiro IDE hooks field "hooks" must be an array: %s`, filePath)
|
|
}
|
|
hooks := make([]map[string]any, 0, len(values))
|
|
for index, value := range values {
|
|
hook, err := validateKiroIdeHook(value, index, "Kiro IDE hooks")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hooks = append(hooks, hook)
|
|
}
|
|
return &kiroIdeDocument{
|
|
exists: true, filePath: filePath, root: root, hooks: hooks,
|
|
snapshot: snapshot,
|
|
}, nil
|
|
}
|
|
|
|
func exactKiroIdeKeys(value map[string]any, keys ...string) bool {
|
|
if len(value) != len(keys) {
|
|
return false
|
|
}
|
|
for _, key := range keys {
|
|
if _, exists := value[key]; !exists {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func ownedKiroIdeReference(
|
|
hook map[string]any,
|
|
) (*kiroIdeRuntimeReference, error) {
|
|
specification, managed := kiroIdeSpecification(hook["name"])
|
|
if !managed {
|
|
return nil, nil
|
|
}
|
|
action, ok := hook["action"].(map[string]any)
|
|
if !ok || action["type"] != "command" {
|
|
return nil, nil
|
|
}
|
|
command, ok := action["command"].(string)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
return kiroIdeRuntimeReferenceForCommand(command, specification.scriptName)
|
|
}
|
|
|
|
func managedKiroIdeReference(
|
|
hook map[string]any,
|
|
) (*kiroIdeRuntimeReference, error) {
|
|
specification, managed := kiroIdeSpecification(hook["name"])
|
|
if !managed ||
|
|
!exactKiroIdeKeys(
|
|
hook,
|
|
"name", "description", "trigger", "matcher", "action", "timeout", "enabled",
|
|
) ||
|
|
hook["description"] != specification.description ||
|
|
hook["trigger"] != specification.trigger || hook["matcher"] != ".*" ||
|
|
hook["timeout"] != kiroIdeHookTimeout || hook["enabled"] != true {
|
|
return nil, nil
|
|
}
|
|
action, ok := hook["action"].(map[string]any)
|
|
if !ok || !exactKiroIdeKeys(action, "type", "command") ||
|
|
action["type"] != "command" {
|
|
return nil, nil
|
|
}
|
|
return ownedKiroIdeReference(hook)
|
|
}
|
|
|
|
func requireAvailableKiroIdeHooks(hooks []map[string]any) error {
|
|
for _, hook := range hooks {
|
|
if _, managed := kiroIdeSpecification(hook["name"]); !managed {
|
|
continue
|
|
}
|
|
reference, err := ownedKiroIdeReference(hook)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if reference == nil {
|
|
return fmt.Errorf(
|
|
"Kiro IDE hook name %q conflicts with the Elydora contract",
|
|
hook["name"],
|
|
)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildKiroIdeHook(
|
|
name string,
|
|
runtimePath string,
|
|
scriptPath string,
|
|
) (map[string]any, error) {
|
|
specification, managed := kiroIdeSpecification(name)
|
|
if !managed {
|
|
return nil, fmt.Errorf("unsupported Elydora Kiro IDE hook name %q", name)
|
|
}
|
|
command, err := buildKiroIdeCommand(runtimePath, scriptPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"name": name, "description": specification.description,
|
|
"trigger": specification.trigger, "matcher": ".*",
|
|
"action": map[string]any{"type": "command", "command": command},
|
|
"timeout": kiroIdeHookTimeout, "enabled": true,
|
|
}, nil
|
|
}
|
|
|
|
func withoutManagedKiroIdeHooks(
|
|
hooks []map[string]any,
|
|
agentID string,
|
|
) []map[string]any {
|
|
filtered := make([]map[string]any, 0, len(hooks))
|
|
for _, hook := range hooks {
|
|
reference, err := ownedKiroIdeReference(hook)
|
|
remove := err == nil && reference != nil &&
|
|
(agentID == "" || sameKiroIdeAgentID(reference.agentID, agentID))
|
|
if !remove {
|
|
filtered = append(filtered, hook)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func entirelyManagedKiroIdeDocument(document *kiroIdeDocument) bool {
|
|
if document == nil || !document.exists ||
|
|
!exactKiroIdeKeys(document.root, "version", "hooks") ||
|
|
len(document.hooks) == 0 {
|
|
return false
|
|
}
|
|
for _, hook := range document.hooks {
|
|
reference, err := managedKiroIdeReference(hook)
|
|
if err != nil || reference == nil {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func renderKiroIdeDocument(
|
|
document *kiroIdeDocument,
|
|
hooks []map[string]any,
|
|
) (*kiroIdeRenderedDocument, error) {
|
|
if document == nil {
|
|
return nil, fmt.Errorf("Kiro IDE rendering requires a hook document")
|
|
}
|
|
if !document.exists && len(hooks) == 0 {
|
|
return &kiroIdeRenderedDocument{document: document}, nil
|
|
}
|
|
if reflect.DeepEqual(hooks, document.hooks) {
|
|
return &kiroIdeRenderedDocument{document: document}, nil
|
|
}
|
|
if len(hooks) == 0 && entirelyManagedKiroIdeDocument(document) {
|
|
return &kiroIdeRenderedDocument{
|
|
document: document, changed: true, remove: true,
|
|
}, nil
|
|
}
|
|
root := cloneKiroIdeObject(document.root)
|
|
root["version"] = "v1"
|
|
values := make([]any, 0, len(hooks))
|
|
for _, hook := range hooks {
|
|
values = append(values, hook)
|
|
}
|
|
root["hooks"] = values
|
|
next, err := json.MarshalIndent(root, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("encode Kiro IDE hooks: %w", err)
|
|
}
|
|
next = append(next, '\n')
|
|
validation := &managedFileSnapshot{contents: next}
|
|
if _, err := parseKiroIdeDocument(document.filePath, validation); err != nil {
|
|
return nil, fmt.Errorf("validate rendered Kiro IDE hooks: %w", err)
|
|
}
|
|
return &kiroIdeRenderedDocument{
|
|
document: document,
|
|
changed: !document.exists || !bytes.Equal(next, document.snapshot.contents),
|
|
next: next,
|
|
}, nil
|
|
}
|
|
|
|
func kiroIdeRuntimeContracts(
|
|
hooks []map[string]any,
|
|
) ([]kiroIdeRuntimeContract, error) {
|
|
managedHooks := make([]map[string]any, 0, 2)
|
|
for _, hook := range hooks {
|
|
_, managed := kiroIdeSpecification(hook["name"])
|
|
if managed {
|
|
managedHooks = append(managedHooks, hook)
|
|
}
|
|
}
|
|
if len(managedHooks) != 2 {
|
|
return []kiroIdeRuntimeContract{}, nil
|
|
}
|
|
nodePath, err := resolveNodeRuntime()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var guard, audit *kiroIdeRuntimeReference
|
|
for _, hook := range managedHooks {
|
|
reference, err := currentKiroIdeReference(hook, nodePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if reference == nil {
|
|
return []kiroIdeRuntimeContract{}, nil
|
|
}
|
|
if hook["name"] == kiroIdeGuardName {
|
|
if guard != nil {
|
|
return []kiroIdeRuntimeContract{}, nil
|
|
}
|
|
guard = reference
|
|
} else {
|
|
if audit != nil {
|
|
return []kiroIdeRuntimeContract{}, nil
|
|
}
|
|
audit = reference
|
|
}
|
|
}
|
|
if guard == nil || audit == nil ||
|
|
!sameKiroIdeAgentID(guard.agentID, audit.agentID) {
|
|
return []kiroIdeRuntimeContract{}, nil
|
|
}
|
|
return []kiroIdeRuntimeContract{{
|
|
agentID: guard.agentID, guardPath: guard.scriptPath,
|
|
auditPath: audit.scriptPath,
|
|
}}, nil
|
|
}
|
|
|
|
func parseKiroIdeLegacyDocument(
|
|
filePath string,
|
|
snapshot *managedFileSnapshot,
|
|
) (*kiroIdeLegacyDocument, error) {
|
|
document := &kiroIdeLegacyDocument{filePath: filePath, snapshot: snapshot}
|
|
if snapshot == nil {
|
|
return document, nil
|
|
}
|
|
label := fmt.Sprintf("legacy Kiro IDE hook at %s", filePath)
|
|
root, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exactKiroIdeKeys(root, "name", "hooks") || root["name"] != "Elydora Audit" {
|
|
return document, nil
|
|
}
|
|
hooks, ok := root["hooks"].(map[string]any)
|
|
if !ok || !exactKiroIdeKeys(hooks, "pre_tool_use", "post_tool_use") {
|
|
return document, nil
|
|
}
|
|
guard, ok := hooks["pre_tool_use"].(map[string]any)
|
|
if !ok || !exactKiroIdeKeys(guard, "command", "timeout_ms") ||
|
|
guard["timeout_ms"] != float64(5000) {
|
|
return document, nil
|
|
}
|
|
audit, ok := hooks["post_tool_use"].(map[string]any)
|
|
if !ok || !exactKiroIdeKeys(audit, "command", "timeout_ms") ||
|
|
audit["timeout_ms"] != float64(5000) {
|
|
return document, nil
|
|
}
|
|
guardReference, err := legacyKiroIdeGoReference(guard["command"], kiroIdeGuardScript)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
auditReference, err := legacyKiroIdeGoReference(audit["command"], kiroIdeAuditScript)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if guardReference == nil || auditReference == nil ||
|
|
!sameKiroIdeAgentID(guardReference.agentID, auditReference.agentID) {
|
|
return document, nil
|
|
}
|
|
document.contract = &kiroIdeRuntimeContract{
|
|
agentID: guardReference.agentID, guardPath: guardReference.scriptPath,
|
|
auditPath: auditReference.scriptPath,
|
|
}
|
|
return document, nil
|
|
}
|