diff --git a/AGENTS.md b/AGENTS.md index 445e0c2..0fd5488 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ This repository publishes the open-source server, console, integration catalog, - Use each provider's documented blocking mechanism and failure mode. - Report installation as healthy only when a complete contract references all required runtime files. - Keep stable, legacy, and early-access contracts explicit, including their activation commands. -- Resolve stable Kimi hooks through `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and treat an empty override as the default. Activate `~/.kimi/config.toml` only when the legacy home exists. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules with ten-second commands; stable Kimi supports its current sixteen-event schema and legacy kimi-cli supports its thirteen-event schema. Preserve the native snake_case payload, propagate exit code `2`, and keep audit delivery fail-open with owner-only error logs. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit every detected config, generated runtime, runtime config, and private key through one rollback-capable transaction. +- Resolve stable Kimi hooks through `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and treat an empty override as the default. Activate `~/.kimi/config.toml` only when the legacy home exists, ignore executable lookup as activation evidence, and collapse equal stable and legacy paths to the stable contract. Register exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` rules with ten-second commands; stable Kimi supports its current sixteen-event schema and legacy kimi-cli supports its thirteen-event schema. Preserve the native snake_case payload, propagate exit code `2`, and keep audit delivery fail-open with owner-only error logs. Use encoded PowerShell commands on Windows and exact two-argument shell commands on Unix. Commit every detected config, generated runtime, runtime config, and private key through one rollback-capable transaction. Status requires strict runtime metadata, a canonical private key, exact identity, and non-empty physical scripts. - Preserve Kimi TOML comments, array style, and unrelated formatting through syntax-preserving edits, then parse every selected document before writing any file. - Write Grok Build integrations to a native user hook file under non-empty `GROK_HOME` or `~/.grok`; keep Claude Code and Cursor compatibility sources read-only. - Write Auggie hooks only to `~/.augment/settings.json`; keep system and workspace settings read-only. Generate `.cmd` wrappers on Windows and `.sh` wrappers on Unix because Auggie dispatches supported script paths, and express hook timeouts in milliseconds. diff --git a/sdks/go/README.md b/sdks/go/README.md index 9b7c5b1..bfa5d5b 100644 --- a/sdks/go/README.md +++ b/sdks/go/README.md @@ -86,7 +86,7 @@ Cline installation writes `PreToolUse.mjs` and `PostToolUse.mjs` to `$CLINE_DIR/ 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`. -Kimi installation follows detected runtime evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default. Legacy `kimi-cli` uses `~/.kimi/config.toml`; Elydora updates both contracts only when both runtimes are present. +Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal. Grok Build installation writes native global hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). Project hooks still follow Grok's `/hooks-trust` workflow; Elydora leaves project, Claude Code, and Cursor compatibility files unchanged. diff --git a/sdks/go/cmd/elydora/plugins/codex_command.go b/sdks/go/cmd/elydora/plugins/codex_command.go index d76b278..dd42168 100644 --- a/sdks/go/cmd/elydora/plugins/codex_command.go +++ b/sdks/go/cmd/elydora/plugins/codex_command.go @@ -2,6 +2,7 @@ package plugins import ( "encoding/base64" + "encoding/binary" "os" "path/filepath" "runtime" @@ -29,8 +30,7 @@ func encodeCodexPowerShell(source string) string { runes := utf16.Encode([]rune(source)) raw := make([]byte, len(runes)*2) for index, value := range runes { - raw[index*2] = byte(value) - raw[index*2+1] = byte(value >> 8) + binary.LittleEndian.PutUint16(raw[index*2:], value) } return base64.StdEncoding.EncodeToString(raw) } diff --git a/sdks/go/cmd/elydora/plugins/codex_installation.go b/sdks/go/cmd/elydora/plugins/codex_installation.go index 5db4e4f..c6d3871 100644 --- a/sdks/go/cmd/elydora/plugins/codex_installation.go +++ b/sdks/go/cmd/elydora/plugins/codex_installation.go @@ -1,15 +1,10 @@ package plugins import ( - "encoding/base64" "encoding/json" "fmt" - "net/url" "os" "path/filepath" - "strconv" - "strings" - "unicode" ) type codexRuntimePaths struct { @@ -21,51 +16,6 @@ type codexRuntimePaths struct { auditPath string } -func validateCodexPrivateKey(value string) error { - seed, err := base64.RawURLEncoding.DecodeString(value) - if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value { - return fmt.Errorf("private key must be a canonical 32-byte base64url value") - } - return nil -} - -func validateCodexBaseURL(value string) error { - if strings.ContainsRune(value, '\\') { - return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") - } - for _, character := range value { - if character < 32 || unicode.IsSpace(character) { - return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") - } - } - parsed, err := url.Parse(value) - if err != nil { - return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") - } - validScheme := strings.EqualFold(parsed.Scheme, "http") || - strings.EqualFold(parsed.Scheme, "https") - if !validScheme || - parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" { - return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") - } - if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { - return fmt.Errorf( - "base URL must exclude credentials, query parameters, and fragments", - ) - } - port := parsed.Port() - if strings.HasSuffix(parsed.Host, ":") { - return fmt.Errorf("base URL must contain a valid port") - } - if port != "" { - value, convertErr := strconv.Atoi(port) - if convertErr != nil || value < 1 || value > 65535 { - return fmt.Errorf("base URL must contain a valid port") - } - } - return nil -} - func validateCodexInstallConfig(config InstallConfig) error { for _, field := range []struct{ name, value string }{ {"agent name", config.AgentName}, @@ -83,10 +33,10 @@ func validateCodexInstallConfig(config InstallConfig) error { if config.AgentName != codexAgentKey { return fmt.Errorf("codex installation requires agent name %s", codexAgentKey) } - if err := validateCodexPrivateKey(config.PrivateKey); err != nil { + if err := validateManagedPrivateKey(config.PrivateKey); err != nil { return err } - return validateCodexBaseURL(config.BaseURL) + return validateManagedBaseURL(config.BaseURL) } func codexAgentPaths(config InstallConfig) (*codexRuntimePaths, error) { diff --git a/sdks/go/cmd/elydora/plugins/kimi.go b/sdks/go/cmd/elydora/plugins/kimi.go index 9e9a49d..2cf4f6c 100644 --- a/sdks/go/cmd/elydora/plugins/kimi.go +++ b/sdks/go/cmd/elydora/plugins/kimi.go @@ -2,74 +2,87 @@ package plugins import ( "fmt" - "strings" ) -type kimiMutation struct { - document kimiDocument - raw []byte - remove bool +// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks. +type KimiPlugin struct { + rename renameFunc } -// KimiPlugin manages Kimi Code and legacy kimi-cli global lifecycle hooks. -type KimiPlugin struct{} +// ManagesGuardRuntime reports that Kimi commits its provider guard with the +// audit runtime and every detected user hook document. +func (p *KimiPlugin) ManagesGuardRuntime() bool { + return true +} -func (p *KimiPlugin) Install(config InstallConfig) error { - if config.AgentID == "" { - return fmt.Errorf("agent ID is required") - } +// PreflightInstall validates every detected hook source and runtime identity. +func (p *KimiPlugin) PreflightInstall(config InstallConfig) error { documents, err := readAllKimiConfigs() if err != nil { return err } - if config.GuardScriptPath == "" { - return fmt.Errorf("guard script path is required") - } - guardExists, err := regularFileExists(config.GuardScriptPath, "Elydora guard runtime") + _, _, err = preflightKimiInstallation(config, documents) + return err +} + +func (p *KimiPlugin) Install(config InstallConfig) error { + documents, err := readAllKimiConfigs() if err != nil { return err } - if !guardExists { - return fmt.Errorf("Elydora guard runtime is missing: %s", config.GuardScriptPath) - } - hookPath, err := hookScriptPath(config.AgentID) + paths, nodePath, err := preflightKimiInstallation(config, documents) if err != nil { return err } - if config.HookScript != "" { - hookPath = config.HookScript - } - nodePath, err := resolveNodeRuntime() + guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) if err != nil { return err } - additions := []kimiHook{ - buildKimiHook("PreToolUse", buildKimiCommand(nodePath, config.GuardScriptPath)), - buildKimiHook("PostToolUse", buildKimiCommand(nodePath, hookPath)), + auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) + if err != nil { + return err } - mutations := make([]kimiMutation, 0, len(documents)) - for _, document := range documents { - raw, err := renderKimiHooks(document, keptKimiHookIndices(document.hooks, ""), additions) + additions := make([]kimiHook, 0, 3) + for _, item := range []struct{ event, command string }{ + {"PreToolUse", guardCommand}, + {"PostToolUse", auditCommand}, + {"PostToolUseFailure", auditCommand}, + } { + hook, err := buildKimiHook(item.event, item.command) if err != nil { return err } - if _, err := parseKimiDocument(document.contract, raw, true); err != nil { - return fmt.Errorf("validate rendered %s: %w", document.contract.label, err) - } - mutations = append(mutations, kimiMutation{document: document, raw: raw}) + additions = append(additions, hook) } - - runtimeConfig := config - runtimeConfig.AgentName = kimiAgentKey - if err := GenerateHookScript(hookPath, runtimeConfig); err != nil { - return fmt.Errorf("generate hook script: %w", err) - } - for _, mutation := range mutations { - if err := writeKimiConfig(mutation.document, mutation.raw); err != nil { + rendered := make([]kimiRenderedDocument, 0, len(documents)) + for _, document := range documents { + keep, err := keptKimiHookIndices(document.hooks, "") + if err != nil { return err } + change, err := renderKimiChange(document, keep, additions) + if err != nil { + return err + } + rendered = append(rendered, change) } - fmt.Printf("%s: global PreToolUse and PostToolUse hooks installed.\n", kimiRuntimeNames(documents)) + changes, err := prepareKimiInstallationChanges(config, paths, rendered) + if err != nil { + return err + } + if err := writeKimiChanges( + changes, + "Install Kimi hooks", + p.rename, + paths.runtimeRoot, + paths.agentDirectory, + ); err != nil { + return err + } + fmt.Printf( + "%s: global PreToolUse, PostToolUse, and PostToolUseFailure hooks installed.\n", + kimiRuntimeNames(documents), + ) return nil } @@ -78,63 +91,51 @@ func (p *KimiPlugin) Uninstall(agentID string) error { if err != nil { return err } - mutations := make([]kimiMutation, 0, len(documents)) + rendered := make([]kimiRenderedDocument, 0, len(documents)) for _, document := range documents { - keep := keptKimiHookIndices(document.hooks, agentID) - if len(keep) == len(document.hooks) { - continue - } - raw, err := renderKimiHooks(document, keep, nil) + keep, err := keptKimiHookIndices(document.hooks, agentID) if err != nil { return err } - if _, err := parseKimiDocument(document.contract, raw, true); err != nil { - return fmt.Errorf("validate rendered %s: %w", document.contract.label, err) - } - mutations = append(mutations, kimiMutation{ - document: document, - raw: raw, - remove: strings.TrimSpace(string(raw)) == "", - }) - } - for _, mutation := range mutations { - if mutation.remove { - if err := removeKimiConfig(mutation.document); err != nil { - return err - } - continue - } - if err := writeKimiConfig(mutation.document, mutation.raw); err != nil { + change, err := renderKimiChange(document, keep, nil) + if err != nil { return err } + rendered = append(rendered, change) } - return nil + changes, err := prepareKimiUninstallChanges(rendered) + if err != nil { + return err + } + return writeKimiChanges( + changes, + "Uninstall Kimi hooks", + p.rename, + "", + "", + ) } func (p *KimiPlugin) Status() (PluginStatus, error) { documents, err := readAllKimiConfigs() + entry := SupportedAgents[kimiAgentKey] status := PluginStatus{ - AgentName: kimiAgentKey, - DisplayName: "Kimi Code", + AgentName: kimiAgentKey, DisplayName: entry.Name, } if err != nil { return status, err } status.ConfigPath = documents[0].contract.configPath - contracts := make([]kimiRuntimeContract, 0, len(documents)) - for _, document := range documents { - contract := kimiRuntimeForDocument(document) - if contract == nil { - continue - } - contracts = append(contracts, *contract) - status.ConfigPath = contract.configPath + contracts, err := kimiRuntimeContracts(documents) + if err != nil { + return status, err } if len(contracts) == 0 { return status, nil } + status.ConfigPath = contracts[len(contracts)-1].configPath status.HookConfigured = true - status.HookScriptExists, err = kimiRuntimeScriptsExist(contracts) + status.HookScriptExists, err = kimiRuntimeFilesExist(contracts) if err != nil { return status, err } diff --git a/sdks/go/cmd/elydora/plugins/kimi_command.go b/sdks/go/cmd/elydora/plugins/kimi_command.go new file mode 100644 index 0000000..f894d59 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_command.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "fmt" + "path/filepath" + "runtime" + "strings" +) + +type kimiRuntimeReference struct { + agentID string + scriptPath string +} + +func sameKimiPath(left, right string) bool { + left = filepath.Clean(left) + right = filepath.Clean(right) + if absolute, err := filepath.Abs(left); err == nil { + left = absolute + } + if absolute, err := filepath.Abs(right); err == nil { + right = absolute + } + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func sameKimiAgentID(left, right string) bool { + if runtime.GOOS == "windows" { + return strings.EqualFold(left, right) + } + return left == right +} + +func buildKimiCommand(runtimePath, scriptPath string) (string, error) { + if !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) { + return "", fmt.Errorf("kimi hook commands require absolute runtime and script paths") + } + if runtime.GOOS == "windows" { + return codexWindowsCommand(runtimePath, scriptPath), nil + } + return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath), nil +} + +func readKimiLegacyWindowsArgument(command string, start int) (string, int, bool) { + if start >= len(command) { + return "", start, false + } + if command[start] == '"' { + closing := strings.IndexByte(command[start+1:], '"') + if closing < 0 { + return "", start, false + } + closing += start + 1 + return command[start+1 : closing], closing + 1, true + } + end := start + for end < len(command) && command[end] != ' ' { + if command[end] == '"' || command[end] == '\t' || command[end] == '\r' || command[end] == '\n' { + return "", start, false + } + end++ + } + return command[start:end], end, end > start +} + +func parseKimiLegacyWindowsCommand(command string) (string, string, bool) { + runtimePath, next, ok := readKimiLegacyWindowsArgument(command, 0) + if !ok || next >= len(command) || command[next] != ' ' { + return "", "", false + } + scriptPath, end, ok := readKimiLegacyWindowsArgument(command, next+1) + if !ok || end != len(command) { + return "", "", false + } + expected := quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) + return runtimePath, scriptPath, command == expected +} + +func parseKimiCommand(command string) (string, string, bool) { + if runtimePath, scriptPath, ok := parseCodexPOSIXCommand(command); ok { + return runtimePath, scriptPath, true + } + if runtimePath, scriptPath, ok := parseCodexWindowsCommand(command); ok { + return runtimePath, scriptPath, true + } + return parseKimiLegacyWindowsCommand(command) +} + +func isKimiNodeExecutable(path string) bool { + name := filepath.Base(path) + return name == "node" || strings.EqualFold(name, "node.exe") +} + +func kimiRuntimeReferenceForCommand( + command string, + scriptName string, +) (*kimiRuntimeReference, error) { + runtimePath, scriptPath, ok := parseKimiCommand(command) + if !ok || !filepath.IsAbs(runtimePath) || !filepath.IsAbs(scriptPath) || + !isKimiNodeExecutable(runtimePath) || filepath.Base(scriptPath) != scriptName { + return nil, nil + } + agentDirectory := filepath.Dir(scriptPath) + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) { + return nil, nil + } + agentID := filepath.Base(agentDirectory) + if agentID == "" || agentID == "." || agentID == ".." { + return nil, nil + } + return &kimiRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_contract.go b/sdks/go/cmd/elydora/plugins/kimi_contract.go index a4fc96e..61e4883 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_contract.go +++ b/sdks/go/cmd/elydora/plugins/kimi_contract.go @@ -2,7 +2,6 @@ package plugins import ( "fmt" - "path/filepath" "runtime" "sort" "strings" @@ -38,22 +37,26 @@ var kimiModernEvents = mergeStringSets(kimiSharedEvents, stringSet( )) type kimiContract struct { - runtimeName string - label string - configPath string - events map[string]struct{} + generation string + runtimeName string + label string + directoryLabel string + configPath string + events map[string]struct{} } type kimiHook struct { - event string - matcher *string - command string - timeout *int64 + event string + matcher *string + command string + timeout *int64 + fieldCount int } type kimiRuntimeContract struct { - guard string - audit string + agentID string + guardPath string + auditPath string configPath string } @@ -112,7 +115,7 @@ func validateKimiHook(value any, contract kimiContract, index int) (kimiHook, er return kimiHook{}, fmt.Errorf("%s hook %d requires a non-empty command", contract.label, index+1) } - hook := kimiHook{event: event, command: command} + hook := kimiHook{event: event, command: command, fieldCount: len(object)} if value, exists := object["matcher"]; exists { matcher, ok := value.(string) if !ok { @@ -154,59 +157,124 @@ func kimiHooks(root map[string]any, contract kimiContract) ([]kimiHook, error) { return hooks, nil } -func normalizeKimiPath(value string) string { - normalized := filepath.ToSlash(value) - if runtime.GOOS == "windows" { - return strings.ToLower(normalized) +func buildKimiHook(event, command string) (kimiHook, error) { + switch event { + case "PreToolUse", "PostToolUse", "PostToolUseFailure": + default: + return kimiHook{}, fmt.Errorf("unsupported managed Kimi event: %s", event) } - return normalized + timeout := kimiHookTimeoutSeconds + return kimiHook{ + event: event, command: command, timeout: &timeout, fieldCount: 3, + }, nil } -func kimiCommandEndsWithPath(command, filePath string) bool { - normalized := strings.TrimSpace(normalizeKimiPath(command)) - expected := normalizeKimiPath(filePath) - return strings.HasSuffix(normalized, expected) || - strings.HasSuffix(normalized, expected+`'`) || - strings.HasSuffix(normalized, expected+`"`) +func managedKimiReference( + hook kimiHook, + event string, + scriptName string, +) (*kimiRuntimeReference, error) { + if hook.fieldCount != 3 || hook.event != event || hook.matcher != nil || + hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds { + return nil, nil + } + return kimiRuntimeReferenceForCommand(hook.command, scriptName) } -func isManagedKimiHook(hook kimiHook, event, scriptName, agentID string) bool { - if hook.event != event || hook.matcher != nil || hook.timeout == nil || *hook.timeout != kimiHookTimeoutSeconds { - return false +func managedKimiEvent(event string) (string, string, bool) { + switch event { + case "PreToolUse": + return event, kimiGuardScript, true + case "PostToolUse", "PostToolUseFailure": + return event, kimiAuditScript, true + default: + return "", "", false } - if agentID != "" { - expected := normalizeKimiPath(filepath.Join(".elydora", agentID, scriptName)) - return kimiCommandEndsWithPath(hook.command, "/"+expected) - } - normalized := normalizeKimiPath(hook.command) - return strings.Contains(normalized, "/.elydora/") && - kimiCommandEndsWithPath(hook.command, "/"+normalizeKimiPath(scriptName)) } -func keptKimiHookIndices(hooks []kimiHook, agentID string) []int { +func keptKimiHookIndices(hooks []kimiHook, agentID string) ([]int, error) { indices := make([]int, 0, len(hooks)) for index, hook := range hooks { - managed := isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, agentID) || - isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, agentID) - if !managed { + event, scriptName, managedEvent := managedKimiEvent(hook.event) + var reference *kimiRuntimeReference + var err error + if managedEvent { + reference, err = managedKimiReference(hook, event, scriptName) + if err != nil { + return nil, err + } + } + remove := reference != nil && + (agentID == "" || sameKimiAgentID(reference.agentID, agentID)) + if !remove { indices = append(indices, index) } } - return indices + return indices, nil } -func kimiRuntimeForDocument(document kimiDocument) *kimiRuntimeContract { - var guard, audit string - for _, hook := range document.hooks { - if isManagedKimiHook(hook, "PreToolUse", kimiGuardScript, "") { - guard = hook.command - } - if isManagedKimiHook(hook, "PostToolUse", kimiAuditScript, "") { - audit = hook.command - } +func kimiReferenceKey(agentID string) string { + if runtime.GOOS == "windows" { + return strings.ToLower(agentID) } - if guard == "" || audit == "" { - return nil + return agentID +} + +func kimiReferencesForEvent( + hooks []kimiHook, + event string, + scriptName string, +) (map[string][]kimiRuntimeReference, error) { + result := map[string][]kimiRuntimeReference{} + for _, hook := range hooks { + reference, err := managedKimiReference(hook, event, scriptName) + if err != nil { + return nil, err + } + if reference == nil { + continue + } + key := kimiReferenceKey(reference.agentID) + result[key] = append(result[key], *reference) } - return &kimiRuntimeContract{guard: guard, audit: audit, configPath: document.contract.configPath} + return result, nil +} + +func kimiRuntimeContracts(documents []kimiDocument) ([]kimiRuntimeContract, error) { + contracts := make([]kimiRuntimeContract, 0) + for _, document := range documents { + guards, err := kimiReferencesForEvent(document.hooks, "PreToolUse", kimiGuardScript) + if err != nil { + return nil, err + } + successes, err := kimiReferencesForEvent(document.hooks, "PostToolUse", kimiAuditScript) + if err != nil { + return nil, err + } + failures, err := kimiReferencesForEvent( + document.hooks, "PostToolUseFailure", kimiAuditScript, + ) + if err != nil { + return nil, err + } + keys := make([]string, 0, len(guards)) + for key := range guards { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + guard := guards[key] + success := successes[key] + failure := failures[key] + if len(guard) != 1 || len(success) != 1 || len(failure) != 1 || + !sameKimiPath(success[0].scriptPath, failure[0].scriptPath) { + continue + } + contracts = append(contracts, kimiRuntimeContract{ + agentID: guard[0].agentID, guardPath: guard[0].scriptPath, + auditPath: success[0].scriptPath, configPath: document.contract.configPath, + }) + } + } + return contracts, nil } diff --git a/sdks/go/cmd/elydora/plugins/kimi_installation.go b/sdks/go/cmd/elydora/plugins/kimi_installation.go new file mode 100644 index 0000000..c449e3a --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_installation.go @@ -0,0 +1,258 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +type kimiRuntimePaths struct { + runtimeRoot string + agentDirectory string + configPath string + keyPath string + guardPath string + auditPath string +} + +type kimiRenderedDocument struct { + document kimiDocument + next []byte +} + +func validateKimiInstallConfig(config InstallConfig) error { + for _, field := range []struct{ name, value string }{ + {"agent name", config.AgentName}, + {"organization ID", config.OrgID}, + {"agent ID", config.AgentID}, + {"key ID", config.KID}, + {"private key", config.PrivateKey}, + {"base URL", config.BaseURL}, + {"guard script path", config.GuardScriptPath}, + } { + if field.value == "" { + return fmt.Errorf("%s is required", field.name) + } + } + if config.AgentName != kimiAgentKey { + return fmt.Errorf("kimi installation requires agent name %s", kimiAgentKey) + } + if strings.TrimSpace(config.OrgID) == "" { + return fmt.Errorf("organization ID is required") + } + if strings.TrimSpace(config.KID) == "" { + return fmt.Errorf("key ID is required") + } + if config.Token != "" && strings.TrimSpace(config.Token) == "" { + return fmt.Errorf("token must contain a non-whitespace value when provided") + } + if err := validateManagedPrivateKey(config.PrivateKey); err != nil { + return err + } + return validateManagedBaseURL(config.BaseURL) +} + +func kimiAgentPaths(config InstallConfig) (*kimiRuntimePaths, error) { + if err := validateKimiInstallConfig(config); err != nil { + return nil, err + } + runtimeRoot, err := AgentRuntimeRoot() + if err != nil { + return nil, err + } + agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID) + if err != nil { + return nil, err + } + paths := &kimiRuntimePaths{ + runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, + configPath: filepath.Join(agentDirectory, "config.json"), + keyPath: filepath.Join(agentDirectory, "private.key"), + guardPath: filepath.Join(agentDirectory, kimiGuardScript), + auditPath: filepath.Join(agentDirectory, kimiAuditScript), + } + if !filepath.IsAbs(config.GuardScriptPath) || + !sameKimiPath(config.GuardScriptPath, paths.guardPath) { + return nil, fmt.Errorf( + "guard runtime must use the managed Elydora agent directory: %s", + paths.guardPath, + ) + } + if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) || + !sameKimiPath(config.HookScript, paths.auditPath)) { + return nil, fmt.Errorf( + "audit runtime must use the managed Elydora agent directory: %s", + paths.auditPath, + ) + } + return paths, nil +} + +func preflightKimiInstallation( + config InstallConfig, + documents []kimiDocument, +) (*kimiRuntimePaths, string, error) { + if len(documents) == 0 { + return nil, "", fmt.Errorf("kimi installation requires at least one hook contract") + } + paths, err := kimiAgentPaths(config) + if err != nil { + return nil, "", err + } + if err := validateKimiRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil { + return nil, "", err + } + nodePath, err := resolveNodeRuntime() + if err != nil { + return nil, "", err + } + return paths, nodePath, nil +} + +func buildKimiRuntimeConfig(config InstallConfig) ([]byte, error) { + value := map[string]any{ + "org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID, + "base_url": config.BaseURL, "agent_name": kimiAgentKey, + } + if config.Token != "" { + value["token"] = config.Token + } + encoded, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, fmt.Errorf("encode Elydora runtime config: %w", err) + } + encoded = append(encoded, '\n') + if len(encoded) > maxRuntimeConfigBytes { + return nil, fmt.Errorf( + "elydora runtime config exceeds %d bytes after JSON encoding", + maxRuntimeConfigBytes, + ) + } + return encoded, nil +} + +func renderKimiChange( + document kimiDocument, + keep []int, + additions []kimiHook, +) (kimiRenderedDocument, error) { + next, err := renderKimiHooks(document, keep, additions) + if err != nil { + return kimiRenderedDocument{}, err + } + if strings.TrimSpace(string(next)) != "" { + if _, err := parseKimiDocument(document.contract, next, true); err != nil { + return kimiRenderedDocument{}, fmt.Errorf( + "validate rendered %s: %w", document.contract.label, err, + ) + } + } + return kimiRenderedDocument{document: document, next: next}, nil +} + +func prepareRenderedKimiChange(rendered kimiRenderedDocument) (*fileChange, error) { + document := rendered.document + if document.exists && bytes.Equal(document.raw, rendered.next) { + return nil, nil + } + remove := document.exists && strings.TrimSpace(string(rendered.next)) == "" + return prepareSourceChange( + document.contract.configPath, + document.contract.label, + document.raw, + document.exists, + rendered.next, + 0600, + remove, + ) +} + +func prepareKimiInstallationChanges( + config InstallConfig, + paths *kimiRuntimePaths, + rendered []kimiRenderedDocument, +) ([]*fileChange, error) { + runtimeConfig, err := buildKimiRuntimeConfig(config) + if err != nil { + return nil, err + } + items := []struct { + path, label string + content []byte + mode os.FileMode + }{ + { + paths.guardPath, "Elydora guard runtime", + []byte(generateGuardScript(kimiAgentKey, config.AgentID, "", false)), 0700, + }, + {paths.configPath, "Elydora runtime config", runtimeConfig, 0600}, + {paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600}, + { + paths.auditPath, "Elydora audit runtime", + []byte(buildHookScriptWithOutput( + kimiAgentKey, config.AgentID, "", false, true, + )), 0700, + }, + } + changes := make([]*fileChange, 0, len(items)+len(rendered)) + for _, item := range items { + change, err := prepareFileChange(item.path, item.label, item.content, item.mode) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + for _, document := range rendered { + change, err := prepareRenderedKimiChange(document) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + return changes, nil +} + +func prepareKimiUninstallChanges( + rendered []kimiRenderedDocument, +) ([]*fileChange, error) { + changes := make([]*fileChange, 0, len(rendered)) + for _, document := range rendered { + change, err := prepareRenderedKimiChange(document) + if err != nil { + return nil, err + } + changes = append(changes, change) + } + return changes, nil +} + +func writeKimiChanges( + changes []*fileChange, + label string, + rename renameFunc, + runtimeRoot string, + agentDirectory string, +) error { + hasChanges := false + for _, change := range changes { + if change != nil { + hasChanges = true + break + } + } + if !hasChanges { + return nil + } + if agentDirectory != "" { + if err := EnsurePrivateDirectory(runtimeRoot); err != nil { + return err + } + if err := EnsurePrivateDirectory(agentDirectory); err != nil { + return err + } + } + return writeChanges(changes, label, rename) +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_io.go b/sdks/go/cmd/elydora/plugins/kimi_io.go index 1ae381b..77a308f 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_io.go +++ b/sdks/go/cmd/elydora/plugins/kimi_io.go @@ -4,9 +4,7 @@ import ( "errors" "fmt" "os" - "os/exec" "path/filepath" - "runtime" "strings" "syscall" ) @@ -23,9 +21,8 @@ func isMissingKimiPath(err error) bool { return os.IsNotExist(err) || errors.Is(err, syscall.ENOTDIR) } -func kimiPathExists(path, label string) (bool, error) { - // #nosec G703 -- KIMI_CODE_HOME is an explicit local-user configuration path. - _, err := os.Stat(path) +func kimiEntryExists(path, label string) (bool, error) { + _, err := os.Lstat(path) if err == nil { return true, nil } @@ -35,15 +32,21 @@ func kimiPathExists(path, label string) (bool, error) { return false, fmt.Errorf("inspect %s at %s: %w", label, path, err) } -func legacyKimiCLIOnPath() (bool, error) { - _, err := exec.LookPath("kimi-cli") - if err == nil { - return true, nil +func stableKimiContract(configPath string) kimiContract { + return kimiContract{ + generation: "stable", runtimeName: "Kimi Code", + label: "Kimi Code hooks config", directoryLabel: "Kimi Code home directory", + configPath: configPath, events: kimiModernEvents, } - if errors.Is(err, exec.ErrNotFound) { - return false, nil +} + +func legacyKimiContract(configPath string) kimiContract { + return kimiContract{ + generation: "legacy", runtimeName: "kimi-cli", + label: "kimi-cli legacy hooks config", + directoryLabel: "kimi-cli legacy home directory", + configPath: configPath, events: kimiSharedEvents, } - return false, fmt.Errorf("inspect kimi-cli executable on PATH: %w", err) } func resolveKimiContracts() ([]kimiContract, error) { @@ -51,58 +54,59 @@ func resolveKimiContracts() ([]kimiContract, error) { if err != nil { return nil, err } - explicitHome := os.Getenv("KIMI_CODE_HOME") - kimiHome := explicitHome - if kimiHome == "" { - kimiHome = filepath.Join(home, ".kimi-code") + configuredHome := os.Getenv("KIMI_CODE_HOME") + stableHome := filepath.Join(home, ".kimi-code") + explicitHome := configuredHome != "" + if explicitHome { + stableHome, err = filepath.Abs(configuredHome) + if err != nil { + return nil, fmt.Errorf("resolve KIMI_CODE_HOME at %s: %w", configuredHome, err) + } } - modern := kimiContract{ - runtimeName: "Kimi Code", - label: "Kimi Code hooks config", - configPath: filepath.Join(kimiHome, "config.toml"), - events: kimiModernEvents, + legacyHome := filepath.Join(home, ".kimi") + stable := stableKimiContract(filepath.Join(stableHome, "config.toml")) + legacy := legacyKimiContract(filepath.Join(legacyHome, "config.toml")) + if sameKimiPath(stable.configPath, legacy.configPath) { + return []kimiContract{stable}, nil } - legacy := kimiContract{ - runtimeName: "kimi-cli", - label: "kimi-cli legacy hooks config", - configPath: filepath.Join(home, ".kimi", "config.toml"), - events: kimiSharedEvents, - } - modernDetected := explicitHome != "" - if !modernDetected { - modernDetected, err = kimiPathExists(kimiHome, "Kimi Code home") + + stableDetected := explicitHome + if !stableDetected { + stableDetected, err = kimiEntryExists(stableHome, "Kimi Code home") if err != nil { return nil, err } } - legacyDetected, err := kimiPathExists(legacy.configPath, legacy.label) + legacyDetected, err := kimiEntryExists(legacyHome, "kimi-cli legacy home") if err != nil { return nil, err } - if !legacyDetected { - legacyDetected, err = legacyKimiCLIOnPath() - if err != nil { - return nil, err - } - } - if legacyDetected && !modernDetected { + if legacyDetected && !stableDetected { return []kimiContract{legacy}, nil } if legacyDetected { - return []kimiContract{modern, legacy}, nil + return []kimiContract{stable, legacy}, nil } - return []kimiContract{modern}, nil + return []kimiContract{stable}, nil } func readKimiConfig(contract kimiContract) (kimiDocument, error) { - raw, err := os.ReadFile(contract.configPath) - if err != nil { - if os.IsNotExist(err) { - return parseKimiDocument(contract, nil, false) - } - return kimiDocument{}, fmt.Errorf("read %s at %s: %w", contract.label, contract.configPath, err) + directory := filepath.Dir(contract.configPath) + if _, err := managedPhysicalDirectoryExists(directory, contract.directoryLabel); err != nil { + return kimiDocument{}, err } - return parseKimiDocument(contract, raw, true) + snapshot, err := readManagedFile( + contract.configPath, + contract.label, + maxManagedSourceBytes, + ) + if err != nil { + return kimiDocument{}, err + } + if snapshot == nil { + return parseKimiDocument(contract, nil, false) + } + return parseKimiDocument(contract, snapshot.contents, true) } func readAllKimiConfigs() ([]kimiDocument, error) { @@ -121,32 +125,6 @@ func readAllKimiConfigs() ([]kimiDocument, error) { return documents, nil } -func writeKimiConfig(document kimiDocument, raw []byte) error { - if err := writeHookFileAtomic(document.contract.configPath, raw, 0600); err != nil { - return fmt.Errorf("write %s at %s: %w", document.contract.label, document.contract.configPath, err) - } - return nil -} - -func removeKimiConfig(document kimiDocument) error { - if err := removeHookFile(document.contract.configPath, document.contract.label); err != nil { - return err - } - return nil -} - -func buildKimiCommand(runtimePath, scriptPath string) string { - if runtime.GOOS == "windows" { - return quoteWindowsArgument(runtimePath) + " " + quoteWindowsArgument(scriptPath) - } - return quotePOSIXArgument(runtimePath) + " " + quotePOSIXArgument(scriptPath) -} - -func buildKimiHook(event, command string) kimiHook { - timeout := kimiHookTimeoutSeconds - return kimiHook{event: event, command: command, timeout: &timeout} -} - func kimiRuntimeNames(documents []kimiDocument) string { names := make([]string, 0, len(documents)) for _, document := range documents { diff --git a/sdks/go/cmd/elydora/plugins/kimi_runtime_test.go b/sdks/go/cmd/elydora/plugins/kimi_runtime_test.go new file mode 100644 index 0000000..4b838cd --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_runtime_test.go @@ -0,0 +1,181 @@ +package plugins + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func installKimiRuntimeFixture( + t *testing.T, + status string, +) (*kimiFixture, *codexTestAPI, *httptest.Server) { + t.Helper() + api := &codexTestAPI{status: status} + server := httptest.NewServer(api) + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + fixture.config.BaseURL = server.URL + fixture.config.Token = "ely_test_token" + if err := fixture.plugin.Install(fixture.config); err != nil { + server.Close() + t.Fatalf("install Kimi hooks: %v", err) + } + return fixture, api, server +} + +func TestKimiGeneratedRuntimePreservesSuccessAndFailurePayloads(t *testing.T) { + fixture, api, server := installKimiRuntimeFixture(t, "active") + defer server.Close() + hooks := readKimiTestHooks(t, fixture.modernPath) + guard := managedKimiTestHook(t, hooks, "PreToolUse")["command"].(string) + success := managedKimiTestHook(t, hooks, "PostToolUse")["command"].(string) + failure := managedKimiTestHook(t, hooks, "PostToolUseFailure")["command"].(string) + + if exitCode, stderr := runKimiCommand( + t, guard, fixture, kimiOfficialPayload("PreToolUse"), + ); exitCode != 0 || stderr != "" { + t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr) + } + successPayload := kimiOfficialPayload("PostToolUse") + failurePayload := kimiOfficialPayload("PostToolUseFailure") + for _, item := range []struct { + command string + payload map[string]any + }{{success, successPayload}, {failure, failurePayload}} { + if exitCode, stderr := runKimiCommand( + t, item.command, fixture, item.payload, + ); exitCode != 0 || stderr != "" { + t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr) + } + } + + getAuth, postAuth, operations := api.snapshot() + if getAuth != "Bearer ely_test_token" || postAuth != "Bearer ely_test_token" { + t.Fatalf("authorization headers = %q, %q", getAuth, postAuth) + } + if len(operations) != 2 || + !reflect.DeepEqual(operations[0]["payload"], successPayload) || + !reflect.DeepEqual(operations[1]["payload"], failurePayload) { + t.Fatalf("Kimi native operations = %#v", operations) + } + if operations[0]["prev_chain_hash"] != "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" || + operations[1]["prev_chain_hash"] != operations[0]["chain_hash"] { + t.Fatalf("Kimi operation chain = %#v", operations) + } +} + +func TestKimiGuardPropagatesOfficialBlockExitCode(t *testing.T) { + for _, status := range []string{"frozen", "revoked"} { + t.Run(status, func(t *testing.T) { + fixture, _, server := installKimiRuntimeFixture(t, status) + defer server.Close() + command := managedKimiTestHook( + t, readKimiTestHooks(t, fixture.modernPath), "PreToolUse", + )["command"].(string) + exitCode, stderr := runKimiCommand( + t, command, fixture, kimiOfficialPayload("PreToolUse"), + ) + if exitCode != 2 || !strings.Contains(stderr, "Tool execution blocked") { + t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr) + } + }) + } +} + +func TestKimiFailOpenGuardReportsInputConfigStatusAndAPIFailures(t *testing.T) { + fixture, api, server := installKimiRuntimeFixture(t, "active") + command := managedKimiTestHook( + t, readKimiTestHooks(t, fixture.modernPath), "PreToolUse", + )["command"].(string) + runtimeConfig, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read runtime config: %v", err) + } + + malformedExit, malformedError := runKimiRawCommand( + t, command, fixture, []byte("{ malformed"), + ) + if err := os.WriteFile(fixture.runtimeConfig, []byte("{ malformed"), 0600); err != nil { + t.Fatalf("corrupt runtime config: %v", err) + } + configExit, configError := runKimiCommand( + t, command, fixture, kimiOfficialPayload("PreToolUse"), + ) + if err := os.WriteFile(fixture.runtimeConfig, runtimeConfig, 0600); err != nil { + t.Fatalf("restore runtime config: %v", err) + } + _ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json")) + api.mu.Lock() + api.status = "unknown" + api.mu.Unlock() + statusExit, statusError := runKimiCommand( + t, command, fixture, kimiOfficialPayload("PreToolUse"), + ) + _ = os.Remove(filepath.Join(fixture.agentDir, "status-cache.json")) + server.Close() + apiExit, apiError := runKimiCommand( + t, command, fixture, kimiOfficialPayload("PreToolUse"), + ) + + for _, result := range []struct { + exit int + err string + want string + }{ + {malformedExit, malformedError, "invalid JSON"}, + {configExit, configError, "Failed to read agent config"}, + {statusExit, statusError, "invalid agent status"}, + {apiExit, apiError, "Failed to resolve agent status"}, + } { + if result.exit != 0 || !strings.Contains(result.err, result.want) { + t.Fatalf("fail-open result = %d, %q; want %q", result.exit, result.err, result.want) + } + } +} + +func TestKimiAuditRecordsMalformedInputAndAPIFailures(t *testing.T) { + fixture, api, server := installKimiRuntimeFixture(t, "active") + defer server.Close() + command := managedKimiTestHook( + t, readKimiTestHooks(t, fixture.modernPath), "PostToolUseFailure", + )["command"].(string) + + malformedExit, malformedError := runKimiRawCommand( + t, command, fixture, []byte("{ malformed"), + ) + api.mu.Lock() + api.postStatus = http.StatusServiceUnavailable + api.mu.Unlock() + upstreamExit, upstreamError := runKimiCommand( + t, command, fixture, kimiOfficialPayload("PostToolUseFailure"), + ) + log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log")) + if malformedExit != 0 || malformedError != "" || upstreamExit != 0 || upstreamError != "" || + err != nil || !strings.Contains(string(log), "invalid JSON") || + !strings.Contains(string(log), "HTTP 503") { + t.Fatalf( + "audit failures = (%d, %q), (%d, %q), log=%q, err=%v", + malformedExit, malformedError, upstreamExit, upstreamError, log, err, + ) + } +} + +func TestKimiFailurePayloadIncludesOfficialErrorShape(t *testing.T) { + payload := kimiOfficialPayload("PostToolUseFailure") + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("decode payload: %v", err) + } + if decoded["hook_event_name"] != "PostToolUseFailure" || decoded["error"] == nil { + t.Fatalf("failure payload = %#v", decoded) + } +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_security_test.go b/sdks/go/cmd/elydora/plugins/kimi_security_test.go new file mode 100644 index 0000000..46a978a --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_security_test.go @@ -0,0 +1,282 @@ +package plugins + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestKimiRejectsLinkedConfigBeforeCreatingRuntime(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + target := filepath.Join(t.TempDir(), "config-target.toml") + source := []byte("# protected target\ntelemetry = false\n") + if err := os.WriteFile(target, source, 0600); err != nil { + t.Fatalf("write config target: %v", err) + } + if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0700); err != nil { + t.Fatalf("create Kimi config directory: %v", err) + } + kimiSymlinkOrSkip(t, target, fixture.modernPath) + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("linked config error = %v", err) + } + actual, readErr := os.ReadFile(target) + if readErr != nil || string(actual) != string(source) { + t.Fatalf("linked config target changed: %q, %v", actual, readErr) + } + assertNoKimiRuntimeWrites(t, fixture) +} + +func TestKimiRejectsLinkedConfigAndRuntimeDirectories(t *testing.T) { + for _, kind := range []string{"config", "runtime"} { + t.Run(kind, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + target := filepath.Join(t.TempDir(), kind+"-target") + if err := os.MkdirAll(target, 0700); err != nil { + t.Fatalf("create link target: %v", err) + } + link := fixture.kimiHome + if kind == "runtime" { + link = filepath.Dir(fixture.agentDir) + } + if err := os.MkdirAll(filepath.Dir(link), 0700); err != nil { + t.Fatalf("create link parent: %v", err) + } + kimiSymlinkOrSkip(t, target, link) + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical directory") { + t.Fatalf("linked %s directory error = %v", kind, err) + } + entries, readErr := os.ReadDir(target) + if readErr != nil || len(entries) != 0 { + t.Fatalf("linked directory target changed: %#v, %v", entries, readErr) + } + }) + } +} + +func TestKimiRejectsLinkedRuntimeFilesBeforeConfigWrites(t *testing.T) { + for _, name := range []string{"config", "key", "guard", "audit"} { + t.Run(name, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + path := map[string]string{ + "config": fixture.runtimeConfig, "key": fixture.privateKey, + "guard": fixture.guardPath, "audit": fixture.hookPath, + }[name] + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatalf("create runtime directory: %v", err) + } + target := filepath.Join(t.TempDir(), name+"-target") + source := []byte("external\n") + if err := os.WriteFile(target, source, 0600); err != nil { + t.Fatalf("write runtime target: %v", err) + } + kimiSymlinkOrSkip(t, target, path) + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "physical file") { + t.Fatalf("linked %s error = %v", name, err) + } + actual, readErr := os.ReadFile(target) + if readErr != nil || string(actual) != string(source) { + t.Fatalf("linked runtime target changed: %q, %v", actual, readErr) + } + if _, statErr := os.Stat(fixture.modernPath); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("Kimi config exists after rejection: %v", statErr) + } + }) + } +} + +func TestKimiRejectsOrphanAndMismatchedRuntimeIdentity(t *testing.T) { + for _, testCase := range []struct{ name, config string }{ + {"orphan", ""}, + {"mismatch", `{"agent_name":"kimi","agent_id":"another-agent"}`}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + if err := os.MkdirAll(fixture.agentDir, 0700); err != nil { + t.Fatalf("create agent directory: %v", err) + } + if testCase.config == "" { + if err := os.WriteFile(fixture.guardPath, []byte("orphan\n"), 0700); err != nil { + t.Fatalf("write orphan guard: %v", err) + } + } else if err := os.WriteFile( + fixture.runtimeConfig, []byte(testCase.config), 0600, + ); err != nil { + t.Fatalf("write mismatched config: %v", err) + } + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "runtime") { + t.Fatalf("runtime identity error = %v", err) + } + if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Kimi config exists after rejection: %v", err) + } + }) + } +} + +func TestKimiValidatesIdentityCredentialsAndOriginBeforeWrites(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(*InstallConfig) + }{ + {"agent name", func(config *InstallConfig) { config.AgentName = "codex" }}, + {"organization", func(config *InstallConfig) { config.OrgID = " " }}, + {"agent ID", func(config *InstallConfig) { config.AgentID = "../escape" }}, + {"key ID", func(config *InstallConfig) { config.KID = "\t" }}, + {"private key", func(config *InstallConfig) { config.PrivateKey = "invalid" }}, + {"token", func(config *InstallConfig) { config.Token = " " }}, + {"scheme", func(config *InstallConfig) { config.BaseURL = "file:///tmp/elydora" }}, + {"credentials", func(config *InstallConfig) { config.BaseURL = "https://user:secret@api.elydora.com" }}, + {"query", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com?tenant=one" }}, + {"backslash", func(config *InstallConfig) { config.BaseURL = `https://api.elydora.com\evil` }}, + {"space", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com/invalid path" }}, + {"port", func(config *InstallConfig) { config.BaseURL = "https://api.elydora.com:invalid" }}, + {"guard path", func(config *InstallConfig) { config.GuardScriptPath = "unmanaged.js" }}, + {"audit path", func(config *InstallConfig) { config.HookScript = "unmanaged.js" }}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + testCase.mutate(&fixture.config) + err := fixture.plugin.Install(fixture.config) + if err == nil { + t.Fatal("install accepted invalid configuration") + } + if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Kimi config exists after validation failure: %v", err) + } + assertNoKimiRuntimeWrites(t, fixture) + }) + } +} + +func TestKimiStatusRejectsLinkedRuntimeFiles(t *testing.T) { + for _, name := range []string{"config", "key", "guard", "audit"} { + t.Run(name, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + path := map[string]string{ + "config": fixture.runtimeConfig, "key": fixture.privateKey, + "guard": fixture.guardPath, "audit": fixture.hookPath, + }[name] + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read runtime file: %v", err) + } + target := filepath.Join(t.TempDir(), name+"-target") + if err := os.WriteFile(target, content, 0600); err != nil { + t.Fatalf("write linked target: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("remove runtime file: %v", err) + } + kimiSymlinkOrSkip(t, target, path) + status, err := fixture.plugin.Status() + if err == nil || status.Installed { + t.Fatalf("linked runtime status = %#v, %v", status, err) + } + }) + } +} + +func TestKimiStatusValidatesRuntimeMetadataAndKey(t *testing.T) { + for _, testCase := range []struct { + name string + mutate func(map[string]any) + want string + }{ + {"extra", func(value map[string]any) { value["extra"] = true }, "unsupported field"}, + {"organization", func(value map[string]any) { value["org_id"] = "" }, "org_id is invalid"}, + {"identity", func(value map[string]any) { value["agent_id"] = "other" }, "identity"}, + {"base URL", func(value map[string]any) { value["base_url"] = "file:///tmp" }, "base URL"}, + {"token", func(value map[string]any) { value["token"] = "" }, "token is invalid"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + raw, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read runtime config: %v", err) + } + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatalf("decode runtime config: %v", err) + } + testCase.mutate(value) + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("encode runtime config: %v", err) + } + if err := os.WriteFile(fixture.runtimeConfig, encoded, 0600); err != nil { + t.Fatalf("write runtime config: %v", err) + } + status, err := fixture.plugin.Status() + if err == nil || status.Installed || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("invalid runtime status = %#v, %v", status, err) + } + }) + } + + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + if err := os.WriteFile(fixture.privateKey, []byte("invalid"), 0600); err != nil { + t.Fatalf("write invalid private key: %v", err) + } + if status, err := fixture.plugin.Status(); err == nil || status.Installed || + !strings.Contains(err.Error(), "canonical 32-byte") { + t.Fatalf("invalid key status = %#v, %v", status, err) + } +} + +func TestKimiOwnershipRequiresExactSupportedFields(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + legacy := legacyKimiCommand(t, fixture.guardPath) + source := "[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + strconv.Quote(legacy) + + "\ntimeout = 10\nmatcher = \"Bash\"\n" + writeOptionalKimiConfig(t, fixture.modernPath, kimiString(source)) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + hooks := readKimiTestHooks(t, fixture.modernPath) + if len(hooks) != 4 || hooks[0]["matcher"] != "Bash" { + t.Fatalf("user-owned matcher hook changed: %#v", hooks) + } + requireKimiManagedTriple(t, hooks) + if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("uninstall Kimi hooks: %v", err) + } + hooks = readKimiTestHooks(t, fixture.modernPath) + if len(hooks) != 1 || hooks[0]["matcher"] != "Bash" { + t.Fatalf("user-owned matcher hook removed: %#v", hooks) + } +} + +func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi 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) + } +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_status.go b/sdks/go/cmd/elydora/plugins/kimi_status.go index 23f0006..626440e 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_status.go +++ b/sdks/go/cmd/elydora/plugins/kimi_status.go @@ -2,70 +2,194 @@ package plugins import ( "fmt" - "os" "path/filepath" + "sort" + "strings" ) -func kimiRuntimeScriptsExist(contracts []kimiRuntimeContract) (bool, error) { - home, err := kimiHomeDirectory() +func readKimiRuntimeConfig(path string) (map[string]any, bool, error) { + snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes) + if err != nil || snapshot == nil { + return nil, snapshot != nil, err + } + label := fmt.Sprintf("Elydora runtime config at %s", path) + config, err := decodeStrictJSONObject(snapshot.contents, label) + return config, true, err +} + +func requireKimiRuntimeString(config map[string]any, field, configPath string) (string, error) { + value, ok := config[field].(string) + if !ok || strings.TrimSpace(value) == "" { + return "", fmt.Errorf( + "elydora runtime config %s is invalid: %s", field, configPath, + ) + } + return value, nil +} + +func validateKimiRuntimeConfig( + config map[string]any, + expectedAgentID string, + configPath string, +) error { + supported := stringSet("org_id", "agent_id", "kid", "base_url", "token", "agent_name") + fields := make([]string, 0, len(config)) + for field := range config { + fields = append(fields, field) + } + sort.Strings(fields) + for _, field := range fields { + if _, ok := supported[field]; !ok { + return fmt.Errorf( + `elydora runtime config has unsupported field %q: %s`, field, configPath, + ) + } + } + if _, err := requireKimiRuntimeString(config, "org_id", configPath); err != nil { + return err + } + if _, err := requireKimiRuntimeString(config, "kid", configPath); err != nil { + return err + } + agentID, err := requireKimiRuntimeString(config, "agent_id", configPath) + if err != nil { + return err + } + if !sameKimiAgentID(agentID, expectedAgentID) || config["agent_name"] != kimiAgentKey { + return fmt.Errorf("elydora runtime identity does not match Kimi hooks: %s", configPath) + } + if _, exists := config["token"]; exists { + if _, err := requireKimiRuntimeString(config, "token", configPath); err != nil { + return err + } + } + baseURL, err := requireKimiRuntimeString(config, "base_url", configPath) + if err != nil { + return err + } + if err := validateManagedBaseURL(baseURL); err != nil { + return fmt.Errorf("elydora runtime config base URL is invalid at %s: %w", configPath, err) + } + return nil +} + +func validateKimiRuntimeIdentity(agentDirectory, agentID string) error { + runtimeRoot := filepath.Dir(agentDirectory) + rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") + if err != nil || !rootExists { + return err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return err + } + configPath := filepath.Join(agentDirectory, "config.json") + config, configExists, err := readKimiRuntimeConfig(configPath) + if err != nil { + return err + } + artifactExists := false + for _, item := range []struct { + path string + label string + limit int64 + }{ + {filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes}, + {filepath.Join(agentDirectory, kimiGuardScript), "Elydora guard runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, kimiAuditScript), "Elydora audit runtime", maxManagedSourceBytes}, + {filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes}, + {filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes}, + } { + exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit) + if inspectErr != nil { + return inspectErr + } + artifactExists = artifactExists || exists + } + if !configExists { + if artifactExists { + return fmt.Errorf( + "elydora runtime identity cannot be verified without config.json: %s", + agentDirectory, + ) + } + return nil + } + configuredID, ok := config["agent_id"].(string) + if !ok || config["agent_name"] != kimiAgentKey || + !sameKimiAgentID(configuredID, agentID) { + return fmt.Errorf( + "elydora runtime config identity does not match Kimi agent %s: %s", + agentID, + configPath, + ) + } + return nil +} + +func kimiRuntimeContractExists(contract kimiRuntimeContract) (bool, error) { + runtimeRoot, err := AgentRuntimeRoot() if err != nil { return false, 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) + agentDirectory := filepath.Dir(contract.guardPath) + if !sameKimiPath(filepath.Dir(agentDirectory), runtimeRoot) || + !sameKimiPath(contract.auditPath, filepath.Join(agentDirectory, kimiAuditScript)) { + return false, nil } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - agentDir := filepath.Join(root, entry.Name()) - guardPath := filepath.Join(agentDir, kimiGuardScript) - auditPath := filepath.Join(agentDir, kimiAuditScript) - if !kimiContractsReferenceRuntime(contracts, guardPath, auditPath) { - continue - } - configPath := filepath.Join(agentDir, "config.json") - config, exists, err := readHookJSONObject(configPath, "Elydora runtime config") + rootExists, err := managedPhysicalDirectoryExists(runtimeRoot, "Elydora runtime directory") + if err != nil || !rootExists { + return false, err + } + directoryExists, err := managedPhysicalDirectoryExists( + agentDirectory, "Elydora agent runtime directory", + ) + if err != nil || !directoryExists { + return false, err + } + + configPath := filepath.Join(agentDirectory, "config.json") + keyPath := filepath.Join(agentDirectory, "private.key") + config, configExists, err := readKimiRuntimeConfig(configPath) + if err != nil { + return false, err + } + key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes) + if err != nil { + return false, err + } + guard, err := readManagedFile(contract.guardPath, "Elydora guard runtime", maxManagedSourceBytes) + if err != nil { + return false, err + } + audit, err := readManagedFile(contract.auditPath, "Elydora audit runtime", maxManagedSourceBytes) + if err != nil { + return false, err + } + if !configExists || key == nil || guard == nil || audit == nil { + return false, nil + } + if err := validateKimiRuntimeConfig(config, contract.agentID, configPath); err != nil { + return false, err + } + if err := validateManagedPrivateKey(string(key.contents)); err != nil { + return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err) + } + return len(guard.contents) > 0 && len(audit.contents) > 0, nil +} + +func kimiRuntimeFilesExist(contracts []kimiRuntimeContract) (bool, error) { + for _, contract := range contracts { + exists, err := kimiRuntimeContractExists(contract) if err != nil { return false, err } - if !exists { - continue + if exists { + return true, nil } - agentName, ok := config["agent_name"].(string) - if !ok { - return false, fmt.Errorf( - `Elydora runtime config at %s field "agent_name" must be a string`, - configPath, - ) - } - if agentName != kimiAgentKey { - continue - } - guardExists, err := regularFileExists(guardPath, "Elydora guard runtime") - if err != nil { - return false, err - } - auditExists, err := regularFileExists(auditPath, "Elydora audit runtime") - if err != nil { - return false, err - } - return guardExists && auditExists, nil } return false, nil } - -func kimiContractsReferenceRuntime(contracts []kimiRuntimeContract, guardPath, auditPath string) bool { - for _, contract := range contracts { - if kimiCommandEndsWithPath(contract.guard, guardPath) && - kimiCommandEndsWithPath(contract.audit, auditPath) { - return true - } - } - return false -} diff --git a/sdks/go/cmd/elydora/plugins/kimi_test.go b/sdks/go/cmd/elydora/plugins/kimi_test.go index e000878..014defb 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_test.go +++ b/sdks/go/cmd/elydora/plugins/kimi_test.go @@ -2,71 +2,90 @@ package plugins import ( "encoding/json" + "errors" "os" "path/filepath" "reflect" + "runtime" "strconv" "strings" "testing" ) -func TestKimiRegistryUsesModernGlobalContract(t *testing.T) { +func TestKimiRegistryUsesStableGlobalContract(t *testing.T) { entry := SupportedAgents[kimiAgentKey] - if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" || entry.ConfigFile != "config.toml" { + if entry.Name != "Kimi Code" || entry.ConfigDir != "~/.kimi-code" || + entry.ConfigFile != "config.toml" { t.Fatalf("Kimi registry entry = %#v", entry) } - if _, ok := NewPlugin(kimiAgentKey).(*KimiPlugin); !ok { + plugin, ok := NewPlugin(kimiAgentKey).(*KimiPlugin) + if !ok || !plugin.ManagesGuardRuntime() { t.Fatalf("Kimi registry plugin = %T", NewPlugin(kimiAgentKey)) } } func TestKimiInstallPreservesBothConfigsAndIsIdempotent(t *testing.T) { - modern := "# modern user config\ndefault_model = \"kimi-code/k3\"\n\n" + - "[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-modern\"\n" + - "timeout = 30 # keep modern hook\n" + modern := "# stable user config\ndefault_model = \"kimi-code/k3\"\n\n" + + "[[hooks]]\nevent = \"SessionStart\"\ncommand = \"existing-stable\"\n" + + "timeout = 30 # keep stable hook\n" legacy := "# legacy user config\ntelemetry = false\n\n" + "[[hooks]]\nevent = \"SessionEnd\"\ncommand = \"existing-legacy\"\n" fixture := prepareKimiFixture(t, kimiFixtureOptions{ - modernConfig: kimiString(modern), - legacyConfig: kimiString(legacy), + modernConfig: kimiString(modern), legacyConfig: kimiString(legacy), }) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Kimi hooks: %v", err) } + first := map[string][]byte{} + for _, path := range []string{fixture.modernPath, fixture.legacyPath} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read first Kimi source: %v", err) + } + first[path] = raw + } if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("repeat Kimi install: %v", err) } for _, contract := range []struct{ path, comment, command string }{ - {fixture.modernPath, "# modern user config", "existing-modern"}, + {fixture.modernPath, "# stable user config", "existing-stable"}, {fixture.legacyPath, "# legacy user config", "existing-legacy"}, } { raw, err := os.ReadFile(contract.path) if err != nil { t.Fatalf("read Kimi config: %v", err) } - if !strings.Contains(string(raw), contract.comment) || !strings.Contains(string(raw), contract.command) { + if !reflect.DeepEqual(raw, first[contract.path]) || + !strings.Contains(string(raw), contract.comment) || + !strings.Contains(string(raw), contract.command) { t.Fatalf("user config changed: %s", raw) } hooks := readKimiTestHooks(t, contract.path) - if len(hooks) != 3 { - t.Fatalf("hooks = %#v, want three entries", hooks) + if len(hooks) != 4 { + t.Fatalf("hooks = %#v, want four entries", hooks) } - requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript)) - requireStrictKimiHook(t, findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript)) + requireKimiManagedTriple(t, hooks) } - defaultModern := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml") - if _, err := os.Stat(defaultModern); !os.IsNotExist(err) { - t.Fatalf("false default migration target exists: %v", err) + defaultStable := filepath.Join(fixture.homeDir, ".kimi-code", "config.toml") + if _, err := os.Stat(defaultStable); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("unselected default config exists: %v", err) + } + for _, path := range []string{ + fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath, + } { + if info, err := os.Stat(path); err != nil || !info.Mode().IsRegular() { + t.Fatalf("managed runtime missing at %s: %v", path, err) + } } } func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) { - modern := "# inline user hook\nhooks = [\n # keep leading\n { event = \"SessionStart\", " + - "command = \"existing-inline\" }, # keep gap\n] # keep array\n" + modern := "# inline user hook\nhooks = [\n # keep leading\n" + + " { event = \"SessionStart\", command = \"existing-inline\" }, # keep gap\n" + + "] # keep array\n" fixture := prepareKimiFixture(t, kimiFixtureOptions{ - modernConfig: kimiString(modern), - withoutLegacyCLI: true, + modernConfig: kimiString(modern), withoutLegacyEvidence: true, }) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install inline Kimi hooks: %v", err) @@ -79,150 +98,181 @@ func TestKimiInstallPreservesInlineHookArrayStyle(t *testing.T) { t.Fatalf("read inline Kimi config: %v", err) } for _, marker := range []string{ - "# inline user hook", - "# keep leading", - "# keep gap", - "# keep array", - "hooks = [", + "# inline user hook", "# keep leading", "# keep gap", "# keep array", "hooks = [", } { if !strings.Contains(string(raw), marker) { t.Fatalf("inline marker %q missing from %s", marker, raw) } } - if len(readKimiTestHooks(t, fixture.modernPath)) != 3 { - t.Fatalf("inline Kimi hooks are not idempotent: %s", raw) + hooks := readKimiTestHooks(t, fixture.modernPath) + if len(hooks) != 4 { + t.Fatalf("inline hooks = %#v", hooks) } + requireKimiManagedTriple(t, hooks) } -func TestKimiModernDefaultAvoidsLegacyMigrationMarker(t *testing.T) { +func TestKimiUninstallPreservesUserOwnedEmptyInlineArray(t *testing.T) { + source := "# user container\nhooks = [] # keep empty array\n" fixture := prepareKimiFixture(t, kimiFixtureOptions{ - useDefaultHome: true, - withoutLegacyCLI: true, + modernConfig: kimiString(source), withoutLegacyEvidence: true, }) - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install modern Kimi hooks: %v", err) - } - if len(readKimiTestHooks(t, fixture.modernPath)) != 2 { - t.Fatalf("modern Kimi config has unexpected hooks") - } - if _, err := os.Stat(fixture.legacyPath); !os.IsNotExist(err) { - t.Fatalf("false legacy migration target exists: %v", err) - } - status, err := fixture.plugin.Status() - if err != nil || !status.Installed { - t.Fatalf("empty KIMI_CODE_HOME status = %#v, %v", status, err) - } -} - -func TestKimiLegacyInstallAvoidsPrematureModernTarget(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{useDefaultHome: true}) - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install legacy Kimi hooks: %v", err) - } - if len(readKimiTestHooks(t, fixture.legacyPath)) != 2 { - t.Fatalf("legacy Kimi config has unexpected hooks") - } - if _, err := os.Stat(fixture.modernPath); !os.IsNotExist(err) { - t.Fatalf("premature modern migration target exists: %v", err) - } - if err := os.Remove(fixture.legacyCLIPath); err != nil { - t.Fatalf("remove legacy CLI marker: %v", err) - } - status, err := fixture.plugin.Status() - if err != nil || !status.Installed { - t.Fatalf("legacy status after PATH change = %#v, %v", status, err) - } -} - -func TestKimiCommandsBlockAndForwardOfficialPayload(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{}) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Kimi hooks: %v", err) } - capturePath := filepath.Join(t.TempDir(), "captured-event.json") - encodedPath, err := json.Marshal(capturePath) - if err != nil { - t.Fatalf("marshal capture path: %v", err) + if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("uninstall Kimi hooks: %v", err) } - source := "const fs = require('node:fs'); const chunks = []; " + - "process.stdin.on('data', chunk => chunks.push(chunk)); " + - "process.stdin.on('end', () => fs.writeFileSync(" + string(encodedPath) + ", Buffer.concat(chunks)));\n" - if err := os.WriteFile(fixture.hookPath, []byte(source), 0700); err != nil { - t.Fatalf("write capture runtime: %v", err) + raw, err := os.ReadFile(fixture.modernPath) + if err != nil { + t.Fatalf("read empty inline config: %v", err) + } + if !strings.Contains(string(raw), "# user container") || + !strings.Contains(string(raw), "# keep empty array") || + len(readKimiTestHooks(t, fixture.modernPath)) != 0 { + t.Fatalf("empty inline array changed: %s", raw) + } +} + +func TestKimiUsesDefaultStableAndLegacyEvidence(t *testing.T) { + t.Run("stable", func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + useDefaultHome: true, withoutLegacyEvidence: true, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install stable Kimi hooks: %v", err) + } + requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.modernPath)) + if _, err := os.Stat(fixture.legacyPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("legacy config exists: %v", err) + } + }) + + t.Run("legacy", func(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + useDefaultHome: true, withoutModernEvidence: true, + }) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install legacy Kimi hooks: %v", err) + } + requireKimiManagedTriple(t, readKimiTestHooks(t, fixture.legacyPath)) + if _, err := os.Stat(fixture.modernPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("stable config exists: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || !status.Installed { + t.Fatalf("legacy Kimi status = %#v, %v", status, err) + } + }) +} + +func TestKimiParsesEverySelectedConfigBeforeRuntimeWrites(t *testing.T) { + modern := "# untouched stable\ndefault_model = \"kimi-code/k3\"\n" + legacy := "[malformed" + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(modern), legacyConfig: kimiString(legacy), + }) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") { + t.Fatalf("install error = %v", err) + } + for path, want := range map[string]string{ + fixture.modernPath: modern, fixture.legacyPath: legacy, + } { + raw, readErr := os.ReadFile(path) + if readErr != nil || string(raw) != want { + t.Fatalf("config %s changed: %q, %v", path, raw, readErr) + } + } + assertNoKimiRuntimeWrites(t, fixture) +} + +func TestKimiRejectsFieldsAndEventsOutsideEachContract(t *testing.T) { + for _, testCase := range []struct { + name, modern, legacy, want string + }{ + { + "unsupported-field", + "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n", + "", `unsupported field "cwd"`, + }, + { + "legacy-stable-event", "", + "[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n", + "unsupported event", + }, + { + "bad-timeout", + "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n", + "", "integer from 1 to 600", + }, + { + "hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n", + "", `field "hooks" must be an array`, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + options := kimiFixtureOptions{} + if testCase.modern != "" { + options.modernConfig = kimiString(testCase.modern) + } + if testCase.legacy != "" { + options.legacyConfig = kimiString(testCase.legacy) + } + fixture := prepareKimiFixture(t, options) + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), testCase.want) { + t.Fatalf("install error = %v, want %q", err, testCase.want) + } + assertNoKimiRuntimeWrites(t, fixture) + }) + } +} + +func TestKimiMigratesExactLegacyCommandsAndPreservesLookalikes(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + guard := legacyKimiCommand(t, fixture.guardPath) + audit := legacyKimiCommand(t, fixture.hookPath) + lookalike := guard + " --inspect" + source := "[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + strconv.Quote(guard) + + "\ntimeout = 10\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " + + strconv.Quote(audit) + "\ntimeout = 10\n\n[[hooks]]\nevent = \"PreToolUse\"\ncommand = " + + strconv.Quote(lookalike) + "\ntimeout = 10\n" + writeOptionalKimiConfig(t, fixture.modernPath, kimiString(source)) + + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("migrate legacy Kimi commands: %v", err) } hooks := readKimiTestHooks(t, fixture.modernPath) - payload := map[string]any{ - "hook_event_name": "PreToolUse", - "session_id": "session-1", - "cwd": fixture.homeDir, - "tool_name": "Bash", - "tool_input": map[string]any{"command": "echo test"}, - "tool_call_id": "call-1", + if len(hooks) != 4 { + t.Fatalf("migrated hooks = %#v", hooks) } - guard := findKimiTestHook(t, hooks, "PreToolUse", kimiGuardScript) - exitCode, stderr := runKimiCommand(t, guard["command"].(string), fixture.homeDir, payload) - if exitCode != 2 || !strings.Contains(stderr, "Agent is frozen by Elydora") { - t.Fatalf("guard exit = %d, stderr = %q", exitCode, stderr) + requireKimiManagedTriple(t, hooks) + if !containsKimiCommand(hooks, lookalike) { + t.Fatalf("lookalike command removed: %#v", hooks) } - payload["hook_event_name"] = "PostToolUse" - payload["tool_output"] = map[string]any{"output": "test"} - audit := findKimiTestHook(t, hooks, "PostToolUse", kimiAuditScript) - exitCode, stderr = runKimiCommand(t, audit["command"].(string), fixture.homeDir, payload) - if exitCode != 0 { - t.Fatalf("audit exit = %d, stderr = %q", exitCode, stderr) + if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("uninstall migrated Kimi hooks: %v", err) } - capturedRaw, err := os.ReadFile(capturePath) - if err != nil { - t.Fatalf("read captured event: %v", err) - } - var captured map[string]any - if err := json.Unmarshal(capturedRaw, &captured); err != nil { - t.Fatalf("decode captured event: %v", err) - } - if !reflect.DeepEqual(captured, payload) { - t.Fatalf("captured event = %#v, want %#v", captured, payload) + hooks = readKimiTestHooks(t, fixture.modernPath) + if len(hooks) != 1 || hooks[0]["command"] != lookalike { + t.Fatalf("remaining hooks = %#v", hooks) } } -func TestKimiStatusAcceptsEitherContractAndRequiresBothScripts(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{}) - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install Kimi hooks: %v", err) - } - if err := os.Remove(fixture.modernPath); err != nil { - t.Fatalf("remove modern config: %v", err) - } - requireKimiInstalledStatus(t, fixture.plugin, fixture.legacyPath) - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("reinstall Kimi hooks: %v", err) - } - if err := os.Remove(fixture.legacyPath); err != nil { - t.Fatalf("remove legacy config: %v", err) - } - requireKimiInstalledStatus(t, fixture.plugin, fixture.modernPath) - if err := os.Remove(fixture.guardPath); err != nil { - t.Fatalf("remove Kimi guard: %v", err) - } - status, err := fixture.plugin.Status() - if err != nil || status.Installed || !status.HookConfigured || status.HookScriptExists { - t.Fatalf("degraded Kimi status = %#v, %v", status, err) +func containsKimiCommand(hooks []map[string]any, command string) bool { + for _, hook := range hooks { + if hook["command"] == command { + return true + } } + return false } -func requireKimiInstalledStatus(t *testing.T, plugin *KimiPlugin, configPath string) { - t.Helper() - status, err := plugin.Status() - if err != nil || !status.Installed || status.ConfigPath != configPath { - t.Fatalf("Kimi status = %#v, %v; want installed at %s", status, err, configPath) - } -} - -func TestKimiUninstallPreservesUserHooks(t *testing.T) { +func TestKimiUninstallPreservesUserConfigsAndRemovesManagedConfigs(t *testing.T) { userHook := "# user hook\n[[hooks]]\nevent = \"SessionStart\"\n" + "command = \"existing-command\"\ntimeout = 30 # keep timeout\n" fixture := prepareKimiFixture(t, kimiFixtureOptions{ - modernConfig: kimiString(userHook), - legacyConfig: kimiString(userHook), + modernConfig: kimiString(userHook), legacyConfig: kimiString(userHook), }) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Kimi hooks: %v", err) @@ -232,159 +282,129 @@ func TestKimiUninstallPreservesUserHooks(t *testing.T) { } for _, path := range []string{fixture.modernPath, fixture.legacyPath} { raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read user config after uninstall: %v", err) - } - if !strings.Contains(string(raw), "# user hook") || !strings.Contains(string(raw), "# keep timeout") { - t.Fatalf("user comments changed: %s", raw) + if err != nil || !strings.Contains(string(raw), "# keep timeout") { + t.Fatalf("user config changed: %s, %v", raw, err) } hooks := readKimiTestHooks(t, path) if len(hooks) != 1 || hooks[0]["command"] != "existing-command" { t.Fatalf("user hook changed: %#v", hooks) } } -} -func TestKimiOwnershipRequiresExactAgentAndScript(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{}) - backupPath := filepath.Join(fixture.homeDir, ".elydora", kimiTestAgentID, "hook.js.backup") - otherAgentPath := filepath.Join(fixture.homeDir, ".elydora", "agent-10", kimiAuditScript) - userHooks := "[[hooks]]\nevent = \"PostToolUse\"\ncommand = " + - strconv.Quote("node "+backupPath) + "\n\n[[hooks]]\nevent = \"PostToolUse\"\ncommand = " + - strconv.Quote("node "+otherAgentPath) + "\n" - if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0755); err != nil { - t.Fatalf("create modern Kimi directory: %v", err) + managed := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := managed.plugin.Install(managed.config); err != nil { + t.Fatalf("install managed Kimi configs: %v", err) } - if err := os.WriteFile(fixture.modernPath, []byte(userHooks), 0600); err != nil { - t.Fatalf("write ownership fixture: %v", err) + if err := managed.plugin.Uninstall(kimiTestAgentID); err != nil { + t.Fatalf("remove managed Kimi configs: %v", err) } - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install Kimi hooks: %v", err) - } - if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { - t.Fatalf("uninstall Kimi hooks: %v", err) - } - hooks := readKimiTestHooks(t, fixture.modernPath) - if len(hooks) != 2 { - t.Fatalf("ownership filter removed user hooks: %#v", hooks) - } - commands := []any{hooks[0]["command"], hooks[1]["command"]} - if !reflect.DeepEqual(commands, []any{"node " + backupPath, "node " + otherAgentPath}) { - t.Fatalf("remaining commands = %#v", commands) - } -} - -func TestKimiUninstallRemovesOwnedConfigs(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{}) - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install Kimi hooks: %v", err) - } - if err := fixture.plugin.Uninstall(kimiTestAgentID); err != nil { - t.Fatalf("uninstall Kimi hooks: %v", err) - } - for _, path := range []string{fixture.modernPath, fixture.legacyPath} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("owned Kimi config remains at %s: %v", path, err) + for _, path := range []string{managed.modernPath, managed.legacyPath} { + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("managed config remains at %s: %v", path, err) } } } -func TestKimiParsesEveryConfigBeforeRuntimeWrites(t *testing.T) { - modern := "# untouched modern\ndefault_model = \"kimi-code/k3\"\n" - legacy := "[malformed" - fixture := prepareKimiFixture(t, kimiFixtureOptions{ - modernConfig: kimiString(modern), - legacyConfig: kimiString(legacy), - }) - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "parse kimi-cli legacy hooks config") { - t.Fatalf("install error = %v", err) +func TestKimiStatusRequiresCompleteTripleAndEveryRuntimeFile(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) } - for path, want := range map[string]string{fixture.modernPath: modern, fixture.legacyPath: legacy} { - raw, readErr := os.ReadFile(path) - if readErr != nil || string(raw) != want { - t.Fatalf("config %s changed: %q, %v", path, raw, readErr) + status, err := fixture.plugin.Status() + if err != nil || !status.Installed || !status.HookConfigured { + t.Fatalf("Kimi status = %#v, %v", status, err) + } + + raw, err := os.ReadFile(fixture.modernPath) + if err != nil { + t.Fatalf("read Kimi config: %v", err) + } + document, err := parseKimiDocument( + stableKimiContract(fixture.modernPath), raw, true, + ) + if err != nil { + t.Fatalf("parse Kimi config: %v", err) + } + keep := make([]int, 0, len(document.hooks)-1) + for index, hook := range document.hooks { + if hook.event != "PostToolUseFailure" { + keep = append(keep, index) } } - requireNoKimiRuntimeWrites(t, fixture) -} + withoutFailure, err := renderKimiHooks(document, keep, nil) + if err != nil { + t.Fatalf("render incomplete Kimi hooks: %v", err) + } + if err := os.WriteFile(fixture.modernPath, withoutFailure, 0600); err != nil { + t.Fatalf("write incomplete Kimi hooks: %v", err) + } + status, err = fixture.plugin.Status() + if err != nil || status.Installed || status.HookConfigured { + t.Fatalf("incomplete Kimi status = %#v, %v", status, err) + } -func TestKimiRejectsInvalidHookContractsBeforeWrites(t *testing.T) { - for _, testCase := range []struct{ name, raw, want string }{ - {"unsupported-field", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ncwd=\"/tmp\"\n", `unsupported field "cwd"`}, - {"unsupported-event", "[[hooks]]\nevent=\"Interrupt\"\ncommand=\"x\"\n", "unsupported event"}, - {"bad-matcher", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\nmatcher=1\n", "matcher must be a string"}, - {"bad-timeout", "[[hooks]]\nevent=\"PreToolUse\"\ncommand=\"x\"\ntimeout=601\n", "integer from 1 to 600"}, - {"hooks-object", "hooks = { event=\"PreToolUse\", command=\"x\" }\n", `field "hooks" must be an array`}, + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("repair Kimi hooks: %v", err) + } + for _, path := range []string{ + fixture.guardPath, fixture.hookPath, fixture.runtimeConfig, fixture.privateKey, } { - t.Run(testCase.name, func(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{ - useDefaultHome: true, - legacyConfig: kimiString(testCase.raw), - }) - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), testCase.want) { - t.Fatalf("install error = %v, want %q", err, testCase.want) + t.Run(filepath.Base(path), func(t *testing.T) { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read runtime file: %v", err) } - raw, readErr := os.ReadFile(fixture.legacyPath) - if readErr != nil || string(raw) != testCase.raw { - t.Fatalf("invalid config changed: %q, %v", raw, readErr) + if err := os.Remove(path); err != nil { + t.Fatalf("remove runtime file: %v", err) + } + status, err := fixture.plugin.Status() + if err != nil || status.Installed || !status.HookConfigured { + t.Fatalf("degraded Kimi status = %#v, %v", status, err) + } + if err := os.WriteFile(path, content, 0600); err != nil { + t.Fatalf("restore runtime file: %v", err) } - requireNoKimiRuntimeWrites(t, fixture) }) } } -func TestKimiRejectsMissingGuardBeforeWrites(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutGuard: true}) - err := fixture.plugin.Install(fixture.config) - if err == nil || !strings.Contains(err.Error(), "guard runtime is missing") { - t.Fatalf("install error = %v", err) - } - for _, path := range []string{fixture.modernPath, fixture.legacyPath} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("config write occurred at %s: %v", path, err) - } - } - requireNoKimiRuntimeWrites(t, fixture) -} - -func requireNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) { - t.Helper() - for _, path := range []string{fixture.hookPath, fixture.runtimeConfig, fixture.privateKey} { - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatalf("runtime write occurred at %s: %v", path, err) - } - } -} - -func TestKimiStatusSurfacesMalformedRuntimeMetadata(t *testing.T) { +func TestKimiInstallationLeavesNoTransactionArtifacts(t *testing.T) { fixture := prepareKimiFixture(t, kimiFixtureOptions{}) if err := fixture.plugin.Install(fixture.config); err != nil { t.Fatalf("install Kimi 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 TestKimiAtomicWritesLeaveNoTemporaryFiles(t *testing.T) { - fixture := prepareKimiFixture(t, kimiFixtureOptions{}) - if err := fixture.plugin.Install(fixture.config); err != nil { - t.Fatalf("install Kimi hooks: %v", err) - } - for _, directory := range []string{filepath.Dir(fixture.modernPath), filepath.Dir(fixture.legacyPath)} { - entries, err := os.ReadDir(directory) - if err != nil { - t.Fatalf("read config directory: %v", err) - } - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".tmp") { - t.Fatalf("temporary Kimi config remains at %s", filepath.Join(directory, entry.Name())) + assertNoKimiTransactionArtifacts(t, fixture.homeDir) + if runtime.GOOS != "windows" { + for _, path := range []string{ + fixture.modernPath, fixture.legacyPath, + fixture.runtimeConfig, fixture.privateKey, + } { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("inspect mode for %s: %v", path, err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("mode for %s = %v", path, info.Mode().Perm()) } } } } + +func TestKimiRuntimeConfigOmitsEmptyOptionalToken(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + fixture.config.Token = "" + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks without token: %v", err) + } + raw, err := os.ReadFile(fixture.runtimeConfig) + if err != nil { + t.Fatalf("read runtime config: %v", err) + } + var config map[string]any + if err := json.Unmarshal(raw, &config); err != nil { + t.Fatalf("decode runtime config: %v", err) + } + if _, exists := config["token"]; exists { + t.Fatalf("empty optional token persisted: %#v", config) + } +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go b/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go index ea2d377..07712bc 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go +++ b/sdks/go/cmd/elydora/plugins/kimi_test_helpers_test.go @@ -15,30 +15,33 @@ import ( "github.com/pelletier/go-toml/v2" ) -const kimiTestAgentID = "agent-1" +const ( + kimiTestAgentID = "agent-1" + kimiPrivateKey = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc" +) type kimiFixtureOptions struct { - modernConfig *string - legacyConfig *string - useDefaultHome bool - withoutLegacyCLI bool - withoutGuard bool + modernConfig *string + legacyConfig *string + useDefaultHome bool + withoutModernEvidence bool + withoutLegacyEvidence bool } type kimiFixture struct { plugin *KimiPlugin config InstallConfig homeDir string - binDir string + projectDir string kimiHome string modernPath string + legacyHome string legacyPath string agentDir string guardPath string hookPath string runtimeConfig string privateKey string - legacyCLIPath string } func kimiString(value string) *string { @@ -47,45 +50,38 @@ func kimiString(value string) *string { func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture { t.Helper() - homeDir := filepath.Join(t.TempDir(), "home with spaces") - binDir := filepath.Join(homeDir, "bin") - kimiHome := filepath.Join(homeDir, "custom kimi code") + root := t.TempDir() + homeDir := filepath.Join(root, "home with spaces and 'quote %ELYDORA_HOOK_PATH%") + projectDir := filepath.Join(root, "project with spaces") + kimiHome := filepath.Join(homeDir, "custom kimi-code") if options.useDefaultHome { kimiHome = filepath.Join(homeDir, ".kimi-code") } modernPath := filepath.Join(kimiHome, "config.toml") - legacyPath := filepath.Join(homeDir, ".kimi", "config.toml") + legacyHome := filepath.Join(homeDir, ".kimi") + legacyPath := filepath.Join(legacyHome, "config.toml") agentDir := filepath.Join(homeDir, ".elydora", kimiTestAgentID) guardPath := filepath.Join(agentDir, kimiGuardScript) hookPath := filepath.Join(agentDir, kimiAuditScript) - if err := os.MkdirAll(agentDir, 0755); err != nil { - t.Fatalf("create agent directory: %v", err) + if err := os.MkdirAll(projectDir, 0700); err != nil { + t.Fatalf("create project directory: %v", err) } - if err := os.MkdirAll(binDir, 0755); err != nil { - t.Fatalf("create binary directory: %v", err) + if options.useDefaultHome && !options.withoutModernEvidence { + if err := os.MkdirAll(kimiHome, 0700); err != nil { + t.Fatalf("create Kimi Code home: %v", err) + } } - if !options.withoutGuard { - source := "process.stdin.resume(); process.stderr.write('Agent is frozen by Elydora.'); process.exit(2);\n" - if err := os.WriteFile(guardPath, []byte(source), 0700); err != nil { - t.Fatalf("write guard runtime: %v", err) + if !options.withoutLegacyEvidence { + if err := os.MkdirAll(legacyHome, 0700); err != nil { + t.Fatalf("create legacy Kimi home: %v", err) } } writeOptionalKimiConfig(t, modernPath, options.modernConfig) writeOptionalKimiConfig(t, legacyPath, options.legacyConfig) - legacyCLIPath := filepath.Join(binDir, "kimi-cli") - if runtime.GOOS == "windows" { - legacyCLIPath += ".cmd" - } - if !options.withoutLegacyCLI { - if err := os.WriteFile(legacyCLIPath, []byte(""), 0700); err != nil { - t.Fatalf("write legacy CLI marker: %v", err) - } - } - originalPath := os.Getenv("PATH") - t.Setenv("PATH", binDir+string(os.PathListSeparator)+originalPath) t.Setenv("HOME", homeDir) t.Setenv("USERPROFILE", homeDir) + t.Setenv("ELYDORA_HOOK_PATH", filepath.Join(root, "expanded-command-fragment")) if options.useDefaultHome { t.Setenv("KIMI_CODE_HOME", "") } else { @@ -93,27 +89,17 @@ func prepareKimiFixture(t *testing.T, options kimiFixtureOptions) *kimiFixture { } return &kimiFixture{ - plugin: &KimiPlugin{}, - homeDir: homeDir, - binDir: binDir, - kimiHome: kimiHome, - config: InstallConfig{ - AgentName: kimiAgentKey, - OrgID: "org-1", - AgentID: kimiTestAgentID, - PrivateKey: "test-key", - KID: "kid-1", - BaseURL: "https://api.elydora.test", - GuardScriptPath: guardPath, - }, - modernPath: modernPath, - legacyPath: legacyPath, - agentDir: agentDir, - guardPath: guardPath, - hookPath: hookPath, + plugin: &KimiPlugin{}, homeDir: homeDir, projectDir: projectDir, + kimiHome: kimiHome, modernPath: modernPath, + legacyHome: legacyHome, legacyPath: legacyPath, + agentDir: agentDir, guardPath: guardPath, hookPath: hookPath, runtimeConfig: filepath.Join(agentDir, "config.json"), privateKey: filepath.Join(agentDir, "private.key"), - legacyCLIPath: legacyCLIPath, + config: InstallConfig{ + AgentName: kimiAgentKey, OrgID: "org-1", AgentID: kimiTestAgentID, + PrivateKey: kimiPrivateKey, KID: "kid-1", Token: "token-1", + BaseURL: "https://api.elydora.test", GuardScriptPath: guardPath, + }, } } @@ -122,7 +108,7 @@ func writeOptionalKimiConfig(t *testing.T, path string, raw *string) { if raw == nil { return } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatalf("create Kimi config directory: %v", err) } if err := os.WriteFile(path, []byte(*raw), 0600); err != nil { @@ -155,36 +141,87 @@ func readKimiTestHooks(t *testing.T, path string) []map[string]any { return hooks } -func findKimiTestHook(t *testing.T, hooks []map[string]any, event, script string) map[string]any { +func managedKimiTestHook(t *testing.T, hooks []map[string]any, event string) map[string]any { t.Helper() - for _, hook := range hooks { - command, _ := hook["command"].(string) - if hook["event"] == event && strings.Contains(command, script) { + for index := len(hooks) - 1; index >= 0; index-- { + hook := hooks[index] + if hook["event"] == event && hook["timeout"] == int64(10) { return hook } } - t.Fatalf("%s hook for %s not found", event, script) + t.Fatalf("managed %s hook not found", event) return nil } -func requireStrictKimiHook(t *testing.T, hook map[string]any) { +func requireStrictKimiHook(t *testing.T, hook map[string]any, event string) { t.Helper() want := map[string]any{ - "event": hook["event"], - "command": hook["command"], - "timeout": int64(10), + "event": event, "command": hook["command"], "timeout": int64(10), } if !reflect.DeepEqual(hook, want) { t.Fatalf("hook = %#v, want strict contract %#v", hook, want) } + command, ok := hook["command"].(string) + if !ok || command == "" { + t.Fatalf("Kimi command = %#v", hook["command"]) + } + if runtime.GOOS == "windows" && !strings.Contains(command, " -EncodedCommand ") { + t.Fatalf("Windows Kimi command is not encoded PowerShell: %q", command) + } } -func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]any) (int, string) { +func requireKimiManagedTriple(t *testing.T, hooks []map[string]any) { + t.Helper() + for _, event := range []string{"PreToolUse", "PostToolUse", "PostToolUseFailure"} { + requireStrictKimiHook(t, managedKimiTestHook(t, hooks, event), event) + } + if managedKimiTestHook(t, hooks, "PostToolUse")["command"] != + managedKimiTestHook(t, hooks, "PostToolUseFailure")["command"] { + t.Fatal("Kimi success and failure audit commands differ") + } +} + +func kimiOfficialPayload(event string) map[string]any { + payload := map[string]any{ + "hook_event_name": event, + "session_id": "session-1", + "cwd": "C:/project", + "tool_name": "Bash", + "tool_input": map[string]any{"command": "echo test"}, + "tool_call_id": "call-1", + } + switch event { + case "PostToolUse": + payload["tool_output"] = map[string]any{"output": "test", "success": true} + case "PostToolUseFailure": + payload["error"] = map[string]any{ + "name": "ToolError", "message": "command failed", "code": "tool.failed", + } + } + return payload +} + +func runKimiCommand( + t *testing.T, + command string, + fixture *kimiFixture, + payload map[string]any, +) (int, string) { t.Helper() encoded, err := json.Marshal(payload) if err != nil { t.Fatalf("marshal Kimi payload: %v", err) } + return runKimiRawCommand(t, command, fixture, encoded) +} + +func runKimiRawCommand( + t *testing.T, + command string, + fixture *kimiFixture, + encoded []byte, +) (int, string) { + t.Helper() var process *exec.Cmd if runtime.GOOS == "windows" { commandFile := filepath.Join(t.TempDir(), "run-kimi-hook.cmd") @@ -195,11 +232,17 @@ func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]an } else { process = exec.Command("sh", "-c", command) } - process.Env = append(os.Environ(), "HOME="+homeDir, "USERPROFILE="+homeDir) + process.Dir = fixture.projectDir + process.Env = append( + os.Environ(), + "HOME="+fixture.homeDir, + "USERPROFILE="+fixture.homeDir, + "ELYDORA_HOOK_PATH=injected-command-fragment", + ) process.Stdin = bytes.NewReader(encoded) var stderr bytes.Buffer process.Stderr = &stderr - err = process.Run() + err := process.Run() if err == nil { return 0, stderr.String() } @@ -210,3 +253,53 @@ func runKimiCommand(t *testing.T, command, homeDir string, payload map[string]an t.Fatalf("run Kimi hook command: %v", err) return -1, stderr.String() } + +func legacyKimiCommand(t *testing.T, scriptPath string) string { + t.Helper() + nodePath, err := resolveNodeRuntime() + if err != nil { + t.Fatalf("resolve Node.js runtime: %v", err) + } + if runtime.GOOS == "windows" { + return quoteWindowsArgument(nodePath) + " " + quoteWindowsArgument(scriptPath) + } + return quotePOSIXArgument(nodePath) + " " + quotePOSIXArgument(scriptPath) +} + +func assertNoKimiRuntimeWrites(t *testing.T, fixture *kimiFixture) { + t.Helper() + for _, path := range []string{ + fixture.runtimeConfig, fixture.privateKey, fixture.guardPath, fixture.hookPath, + } { + if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime file exists at %s: %v", path, err) + } + } +} + +func assertNoKimiTransactionArtifacts(t *testing.T, root string) { + t.Helper() + if _, err := os.Lstat(root); errors.Is(err, os.ErrNotExist) { + return + } + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if strings.HasSuffix(entry.Name(), ".tmp") || + strings.HasSuffix(entry.Name(), ".rollback") { + t.Errorf("transaction artifact remains at %s", path) + } + return nil + }) + if err != nil { + t.Fatalf("walk Kimi fixture: %v", err) + } +} + +func kimiSymlinkOrSkip(t *testing.T, target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("symbolic links unavailable: %v", err) + } +} diff --git a/sdks/go/cmd/elydora/plugins/kimi_toml.go b/sdks/go/cmd/elydora/plugins/kimi_toml.go index ffc6ccd..95f66d2 100644 --- a/sdks/go/cmd/elydora/plugins/kimi_toml.go +++ b/sdks/go/cmd/elydora/plugins/kimi_toml.go @@ -24,6 +24,7 @@ type kimiTomlLayout struct { type kimiDocument struct { contract kimiContract + exists bool raw []byte hooks []kimiHook layout kimiTomlLayout @@ -31,7 +32,9 @@ type kimiDocument struct { func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocument, error) { if !exists { - return kimiDocument{contract: contract, raw: []byte{}, hooks: []kimiHook{}}, nil + return kimiDocument{ + contract: contract, exists: false, raw: []byte{}, hooks: []kimiHook{}, + }, nil } root := map[string]any{} if err := toml.Unmarshal(raw, &root); err != nil { @@ -76,6 +79,7 @@ func parseKimiDocument(contract kimiContract, raw []byte, exists bool) (kimiDocu } return kimiDocument{ contract: contract, + exists: true, raw: append([]byte(nil), raw...), hooks: hooks, layout: layout, diff --git a/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go b/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go new file mode 100644 index 0000000..22c999e --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/kimi_transaction_test.go @@ -0,0 +1,223 @@ +package plugins + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func snapshotKimiFiles(t *testing.T, paths ...string) map[string][]byte { + t.Helper() + result := make(map[string][]byte, len(paths)) + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read snapshot %s: %v", path, err) + } + result[path] = raw + } + return result +} + +func requireKimiSnapshot(t *testing.T, snapshot map[string][]byte) { + t.Helper() + for path, want := range snapshot { + actual, err := os.ReadFile(path) + if err != nil || string(actual) != string(want) { + t.Fatalf("snapshot changed at %s: %q, %v", path, actual, err) + } + } +} + +func TestKimiInstallRollsBackRuntimeAndBothConfigs(t *testing.T) { + stable := []byte("# stable owner config\ndefault_model = \"kimi-code/k3\"\n") + legacy := []byte("# legacy owner config\ntelemetry = false\n") + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(string(stable)), legacyConfig: kimiString(string(legacy)), + }) + fixture.plugin.rename = func(source, destination string) error { + if sameKimiPath(destination, fixture.legacyPath) { + return errors.New("injected legacy config failure") + } + return os.Rename(source, destination) + } + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "injected legacy config failure") { + t.Fatalf("install error = %v", err) + } + for path, want := range map[string][]byte{ + fixture.modernPath: stable, fixture.legacyPath: legacy, + } { + actual, readErr := os.ReadFile(path) + if readErr != nil || string(actual) != string(want) { + t.Fatalf("config changed at %s: %q, %v", path, actual, readErr) + } + } + assertNoKimiRuntimeWrites(t, fixture) + assertNoKimiTransactionArtifacts(t, fixture.homeDir) +} + +func TestKimiInstallPreservesConcurrentConfigMutation(t *testing.T) { + original := "# original owner config\ndefault_model = \"kimi-code/k3\"\n" + concurrent := []byte("# concurrent owner change\ntelemetry = false\n") + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(original), withoutLegacyEvidence: true, + }) + mutated := false + fixture.plugin.rename = func(source, destination string) error { + if !mutated && strings.HasSuffix(source, ".tmp") { + mutated = true + if err := os.WriteFile(fixture.modernPath, concurrent, 0600); err != nil { + return err + } + } + return os.Rename(source, destination) + } + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("concurrent install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.modernPath) + if readErr != nil || string(actual) != string(concurrent) { + t.Fatalf("concurrent config changed: %q, %v", actual, readErr) + } + assertNoKimiRuntimeWrites(t, fixture) + assertNoKimiTransactionArtifacts(t, fixture.homeDir) +} + +func TestKimiInstallDetectsConcurrentConfigIdentityReplacement(t *testing.T) { + original := []byte("# original owner config\n") + fixture := prepareKimiFixture(t, kimiFixtureOptions{ + modernConfig: kimiString(string(original)), withoutLegacyEvidence: true, + }) + mutated := false + fixture.plugin.rename = func(source, destination string) error { + if !mutated && strings.HasSuffix(source, ".tmp") { + mutated = true + external := fixture.modernPath + ".external" + if err := os.Rename(fixture.modernPath, external); err != nil { + return err + } + if err := os.WriteFile(fixture.modernPath, original, 0600); err != nil { + return err + } + } + return os.Rename(source, destination) + } + + err := fixture.plugin.Install(fixture.config) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("concurrent identity error = %v", err) + } + actual, readErr := os.ReadFile(fixture.modernPath) + if readErr != nil || string(actual) != string(original) { + t.Fatalf("replacement config changed: %q, %v", actual, readErr) + } + assertNoKimiRuntimeWrites(t, fixture) + assertNoKimiTransactionArtifacts(t, fixture.homeDir) +} + +func TestKimiUninstallRollsBackBothConfigRemovals(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{}) + if err := fixture.plugin.Install(fixture.config); err != nil { + t.Fatalf("install Kimi hooks: %v", err) + } + paths := []string{ + fixture.guardPath, fixture.runtimeConfig, fixture.privateKey, fixture.hookPath, + fixture.modernPath, fixture.legacyPath, + } + before := snapshotKimiFiles(t, paths...) + removals := 0 + fixture.plugin.rename = func(source, destination string) error { + if strings.HasSuffix(destination, ".rollback") { + removals++ + if removals == 2 { + return errors.New("injected uninstall failure") + } + } + return os.Rename(source, destination) + } + + err := fixture.plugin.Uninstall(kimiTestAgentID) + if err == nil || !strings.Contains(err.Error(), "injected uninstall failure") { + t.Fatalf("uninstall error = %v", err) + } + requireKimiSnapshot(t, before) + assertNoKimiTransactionArtifacts(t, fixture.homeDir) +} + +func TestPreparedKimiInstallDetectsSourceChangeBeforeCommit(t *testing.T) { + fixture := prepareKimiFixture(t, kimiFixtureOptions{withoutLegacyEvidence: true}) + documents, err := readAllKimiConfigs() + if err != nil { + t.Fatalf("read Kimi configs: %v", err) + } + paths, nodePath, err := preflightKimiInstallation(fixture.config, documents) + if err != nil { + t.Fatalf("preflight Kimi installation: %v", err) + } + guardCommand, err := buildKimiCommand(nodePath, paths.guardPath) + if err != nil { + t.Fatalf("build guard command: %v", err) + } + auditCommand, err := buildKimiCommand(nodePath, paths.auditPath) + if err != nil { + t.Fatalf("build audit command: %v", err) + } + additions := make([]kimiHook, 0, 3) + for _, item := range []struct{ event, command string }{ + {"PreToolUse", guardCommand}, + {"PostToolUse", auditCommand}, + {"PostToolUseFailure", auditCommand}, + } { + hook, hookErr := buildKimiHook(item.event, item.command) + if hookErr != nil { + t.Fatalf("build Kimi hook: %v", hookErr) + } + additions = append(additions, hook) + } + rendered := make([]kimiRenderedDocument, 0, len(documents)) + for _, document := range documents { + keep, keepErr := keptKimiHookIndices(document.hooks, "") + if keepErr != nil { + t.Fatalf("select Kimi hooks: %v", keepErr) + } + change, renderErr := renderKimiChange(document, keep, additions) + if renderErr != nil { + t.Fatalf("render Kimi hooks: %v", renderErr) + } + rendered = append(rendered, change) + } + changes, err := prepareKimiInstallationChanges(fixture.config, paths, rendered) + if err != nil { + t.Fatalf("prepare Kimi installation: %v", err) + } + if err := os.MkdirAll(filepath.Dir(fixture.modernPath), 0700); err != nil { + t.Fatalf("create Kimi home: %v", err) + } + concurrent := []byte("# changed after preparation\n") + if err := os.WriteFile(fixture.modernPath, concurrent, 0600); err != nil { + t.Fatalf("write concurrent source: %v", err) + } + + err = writeKimiChanges( + changes, + "Install Kimi hooks", + nil, + paths.runtimeRoot, + paths.agentDirectory, + ) + if err == nil || !strings.Contains(err.Error(), "changed during installation") { + t.Fatalf("prepared install error = %v", err) + } + actual, readErr := os.ReadFile(fixture.modernPath) + if readErr != nil || string(actual) != string(concurrent) { + t.Fatalf("prepared source changed: %q, %v", actual, readErr) + } + assertNoKimiRuntimeWrites(t, fixture) + assertNoKimiTransactionArtifacts(t, fixture.homeDir) +} diff --git a/sdks/go/cmd/elydora/plugins/managed_installation_validation.go b/sdks/go/cmd/elydora/plugins/managed_installation_validation.go new file mode 100644 index 0000000..7e42c97 --- /dev/null +++ b/sdks/go/cmd/elydora/plugins/managed_installation_validation.go @@ -0,0 +1,54 @@ +package plugins + +import ( + "encoding/base64" + "fmt" + "net/url" + "strconv" + "strings" + "unicode" +) + +func validateManagedPrivateKey(value string) error { + seed, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(seed) != 32 || base64.RawURLEncoding.EncodeToString(seed) != value { + return fmt.Errorf("private key must be a canonical 32-byte base64url value") + } + return nil +} + +func validateManagedBaseURL(value string) error { + if strings.ContainsRune(value, '\\') { + return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") + } + for _, character := range value { + if character < 32 || unicode.IsSpace(character) { + return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") + } + } + parsed, err := url.Parse(value) + if err != nil { + return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") + } + validScheme := strings.EqualFold(parsed.Scheme, "http") || + strings.EqualFold(parsed.Scheme, "https") + if !validScheme || parsed.Host == "" || parsed.Hostname() == "" || parsed.Opaque != "" { + return fmt.Errorf("base URL must be an absolute HTTP or HTTPS URL") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf( + "base URL must exclude credentials, query parameters, and fragments", + ) + } + port := parsed.Port() + if strings.HasSuffix(parsed.Host, ":") { + return fmt.Errorf("base URL must contain a valid port") + } + if port != "" { + value, convertErr := strconv.Atoi(port) + if convertErr != nil || value < 1 || value > 65535 { + return fmt.Errorf("base URL must contain a valid port") + } + } + return nil +}