From c60b46b9940ab871fe72f2c3a9ec04fe14c78861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Jul 2026 04:56:41 -0400 Subject: [PATCH] fix: align Kimi hooks with official contracts --- AGENTS.md | 5 +- README.md | 2 +- cmd/elydora/plugins/codex_command.go | 4 +- cmd/elydora/plugins/codex_installation.go | 54 +- cmd/elydora/plugins/kimi.go | 155 ++--- cmd/elydora/plugins/kimi_command.go | 119 ++++ cmd/elydora/plugins/kimi_contract.go | 168 ++++-- cmd/elydora/plugins/kimi_installation.go | 258 +++++++++ cmd/elydora/plugins/kimi_io.go | 124 ++-- cmd/elydora/plugins/kimi_runtime_test.go | 181 ++++++ cmd/elydora/plugins/kimi_security_test.go | 282 +++++++++ cmd/elydora/plugins/kimi_status.go | 230 ++++++-- cmd/elydora/plugins/kimi_test.go | 542 +++++++++--------- cmd/elydora/plugins/kimi_test_helpers_test.go | 219 +++++-- cmd/elydora/plugins/kimi_toml.go | 6 +- cmd/elydora/plugins/kimi_transaction_test.go | 223 +++++++ .../managed_installation_validation.go | 54 ++ 17 files changed, 1991 insertions(+), 635 deletions(-) create mode 100644 cmd/elydora/plugins/kimi_command.go create mode 100644 cmd/elydora/plugins/kimi_installation.go create mode 100644 cmd/elydora/plugins/kimi_runtime_test.go create mode 100644 cmd/elydora/plugins/kimi_security_test.go create mode 100644 cmd/elydora/plugins/kimi_transaction_test.go create mode 100644 cmd/elydora/plugins/managed_installation_validation.go diff --git a/AGENTS.md b/AGENTS.md index 6f95a58..49b7ab6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,9 @@ This repository owns the public Go SDK, the `cmd/elydora` CLI, local signing beh - Resolve Codex user hooks through `$CODEX_HOME/hooks.json` with `~/.codex/hooks.json` as the default and canonicalize configured existing directories. Preserve additive TOML, project, plugin, and managed sources. Register exact `PreToolUse` and `PostToolUse` match-all command groups, retain the native payload, propagate freeze and revocation through exit code `2`, keep guard lookup and audit delivery fail-open with observable errors, and commit user hooks plus all four runtime artifacts through one rollback-capable transaction. Require `/hooks` approval for both definition hashes. - Write Cursor hooks only to `~/.cursor/hooks.json`; keep project and enterprise sources read-only. Preserve user hooks, migrate the prior versionless Elydora contract, audit successful and failed tool calls, emit valid native JSON responses, retain PowerShell exit codes, and commit guard, runtime metadata, private key, audit runtime, and user hooks in one rollback-capable transaction. - Model stable, legacy, and early-access hook generations as explicit contracts. Keep their activation requirements visible in CLI output and README guidance. -- Select Kimi Code and legacy `kimi-cli` contracts from runtime evidence. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; create no cross-runtime migration marker. -- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. +- Select Kimi Code from an explicit `KIMI_CODE_HOME` or an existing `~/.kimi-code` home, and select legacy `kimi-cli` from an existing `~/.kimi` home. An empty `KIMI_CODE_HOME` uses `~/.kimi-code`; executable lookup is outside the activation contract, and equal stable and legacy config paths collapse to the stable contract. +- Preserve Kimi TOML comments and unrelated formatting through range-based edits, then parse the complete rendered document before writing it. Own only exact three-field hooks for `PreToolUse`, `PostToolUse`, and `PostToolUseFailure`; require one complete triple per runtime identity, use encoded PowerShell commands on Windows, and retain the native snake_case payload. +- Generate Kimi guard, config, private key, and audit runtimes together with every selected TOML document in one rollback-capable transaction. Preflight physical paths and existing runtime identity before creating state; status validates the complete runtime metadata, canonical private key, exact agent identity, and non-empty physical scripts. - Keep Grok Build writes inside its native global `$GROK_HOME/hooks/*.json` contract. Treat Claude Code and Cursor compatibility files plus project `.grok/hooks` as read-only integration sources. - 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. - Write Cline hooks only to `$CLINE_DIR/hooks` with `~/.cline/hooks` as the default; keep Documents and workspace hook roots read-only. Preserve official input byte-for-byte and translate guard exit code `2` into Cline's JSON stdout cancellation control. diff --git a/README.md b/README.md index bc6b027..ea29faf 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,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/cmd/elydora/plugins/codex_command.go b/cmd/elydora/plugins/codex_command.go index d76b278..dd42168 100644 --- a/cmd/elydora/plugins/codex_command.go +++ b/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/cmd/elydora/plugins/codex_installation.go b/cmd/elydora/plugins/codex_installation.go index 5db4e4f..c6d3871 100644 --- a/cmd/elydora/plugins/codex_installation.go +++ b/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/cmd/elydora/plugins/kimi.go b/cmd/elydora/plugins/kimi.go index 9e9a49d..2cf4f6c 100644 --- a/cmd/elydora/plugins/kimi.go +++ b/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/cmd/elydora/plugins/kimi_command.go b/cmd/elydora/plugins/kimi_command.go new file mode 100644 index 0000000..f894d59 --- /dev/null +++ b/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/cmd/elydora/plugins/kimi_contract.go b/cmd/elydora/plugins/kimi_contract.go index a4fc96e..61e4883 100644 --- a/cmd/elydora/plugins/kimi_contract.go +++ b/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/cmd/elydora/plugins/kimi_installation.go b/cmd/elydora/plugins/kimi_installation.go new file mode 100644 index 0000000..c449e3a --- /dev/null +++ b/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/cmd/elydora/plugins/kimi_io.go b/cmd/elydora/plugins/kimi_io.go index 1ae381b..77a308f 100644 --- a/cmd/elydora/plugins/kimi_io.go +++ b/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/cmd/elydora/plugins/kimi_runtime_test.go b/cmd/elydora/plugins/kimi_runtime_test.go new file mode 100644 index 0000000..4b838cd --- /dev/null +++ b/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/cmd/elydora/plugins/kimi_security_test.go b/cmd/elydora/plugins/kimi_security_test.go new file mode 100644 index 0000000..46a978a --- /dev/null +++ b/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/cmd/elydora/plugins/kimi_status.go b/cmd/elydora/plugins/kimi_status.go index 23f0006..626440e 100644 --- a/cmd/elydora/plugins/kimi_status.go +++ b/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/cmd/elydora/plugins/kimi_test.go b/cmd/elydora/plugins/kimi_test.go index e000878..014defb 100644 --- a/cmd/elydora/plugins/kimi_test.go +++ b/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/cmd/elydora/plugins/kimi_test_helpers_test.go b/cmd/elydora/plugins/kimi_test_helpers_test.go index ea2d377..07712bc 100644 --- a/cmd/elydora/plugins/kimi_test_helpers_test.go +++ b/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/cmd/elydora/plugins/kimi_toml.go b/cmd/elydora/plugins/kimi_toml.go index ffc6ccd..95f66d2 100644 --- a/cmd/elydora/plugins/kimi_toml.go +++ b/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/cmd/elydora/plugins/kimi_transaction_test.go b/cmd/elydora/plugins/kimi_transaction_test.go new file mode 100644 index 0000000..22c999e --- /dev/null +++ b/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/cmd/elydora/plugins/managed_installation_validation.go b/cmd/elydora/plugins/managed_installation_validation.go new file mode 100644 index 0000000..7e42c97 --- /dev/null +++ b/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 +}